#!/usr/bin/env python3 """ x402pay — a minimal, dependency-light client for the x402 v2 agent-native payment protocol. WHAT IT DOES An x402 v2 paywalled API answers an unprompted request with HTTP 402 and a `PAYMENT-REQUIRED` header (base64 JSON offer). This client: 1. discovers the offer, 2. picks an `accepts` entry, 3. signs an EIP-712 `TransferWithAuthorization` (EIP-3009) with the caller's own key, 4. resends the request with a `PAYMENT-SIGNATURE` header, 5. settles (HTTP 200) or waits (HTTP 202 -> resend the identical body, no new signature). WHY IT EXISTS Every agent that wants to pay a paywalled x402 API has to hand-build this. The two non-obvious parts are (a) the exact EIP-712 domain/type shapes and (b) the fact that USDC FiatTokenV2 requires the signature's v byte to be 27/28 (eth_account already returns 27/28 for EIP-712 — do NOT normalize to 0/1). MIT License — Copyright (c) 2026 Meridian Digital (meridian-hustler). """ from __future__ import annotations import base64 import json import secrets import time import urllib.error import urllib.request from eth_account import Account from eth_account.messages import encode_typed_data UA = "x402pay/1.0 (MIT)" def http(method: str, url: str, body: object = None, extra_headers: dict | None = None, timeout: int = 60): """Plain HTTP helper returning (status, headers_dict, text).""" data = json.dumps(body).encode() if body is not None else None hdr = {"Content-Type": "application/json", "User-Agent": UA} if extra_headers: hdr.update(extra_headers) req = urllib.request.Request(url, data=data, method=method, headers=hdr) try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.status, dict(r.headers), r.read().decode() except urllib.error.HTTPError as e: return e.code, dict(e.headers), e.read().decode() def decode_payment_required(headers: dict): """Return the parsed offer from a 402's PAYMENT-REQUIRED header, or None.""" raw = headers.get("PAYMENT-REQUIRED") or headers.get("payment-required") if not raw: return None try: return json.loads(base64.b64decode(raw)) except Exception: return None def sign_eip3009(entry: dict, wallet: str, private_key: str): """Sign an EIP-712 TransferWithAuthorization for one `accepts` entry. Returns (authorization_dict, signature_hex, typed_data). """ chain_id = int(entry["network"].split(":")[1]) now = int(time.time()) timeout = int(entry.get("maxTimeoutSeconds", 300)) extra = entry.get("extra") or {} auth = { "from": wallet, "to": entry["payTo"], "value": entry["amount"], # string decimal "validAfter": str(now - 60), "validBefore": str(now + timeout), "nonce": "0x" + secrets.token_hex(32), } typed = { "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": extra.get("name", "USD Coin"), "version": extra.get("version", "2"), "chainId": chain_id, "verifyingContract": entry["asset"], }, "message": { "from": auth["from"], "to": auth["to"], "value": int(auth["value"]), "validAfter": int(auth["validAfter"]), "validBefore": int(auth["validBefore"]), "nonce": auth["nonce"], }, } signed = Account.sign_message(encode_typed_data(full_message=typed), private_key=private_key) return auth, "0x" + signed.signature.hex(), typed def build_payment_payload(offer: dict, entry: dict, auth: dict, sig_hex: str): return { "x402Version": 2, "resource": offer.get("resource"), "accepted": entry, "payload": {"signature": sig_hex, "authorization": auth}, } def pay(url: str, body: object, wallet: str, private_key: str, prefer_network: str | None = None, max_poll: int = 5, poll_seconds: int = 6): """Full x402 v2 payment round-trip. Returns (status, response_text, receipt, tx_meta).""" # 1. unprompted request -> expect 402 + offer status, headers, text = http("POST", url, body) if status != 402: return status, text, None, {"error": f"expected 402, got {status}"} offer = json.loads(text) hdr_offer = decode_payment_required(headers) if hdr_offer and isinstance(hdr_offer, dict): offer = hdr_offer # 2. pick accepts entry accepts = offer.get("accepts", []) entry = None if prefer_network: for a in accepts: if a.get("network") == prefer_network: entry = a break if entry is None and accepts: entry = accepts[0] if entry is None: return status, text, None, {"error": "no accepts entry in offer"} # 3. sign EIP-3009 auth, sig_hex, _typed = sign_eip3009(entry, wallet, private_key) # 4. resend with PAYMENT-SIGNATURE payload = build_payment_payload(offer, entry, auth, sig_hex) enc = base64.b64encode(json.dumps(payload).encode()).decode() status, headers, text = http("POST", url, body, extra_headers={"PAYMENT-SIGNATURE": enc}) receipt = headers.get("PAYMENT-RESPONSE") or headers.get("payment-response") # 5. 202 -> poll with the identical body (no new signature) for _ in range(max_poll): if status != 202: break time.sleep(poll_seconds) status, headers, text = http("POST", url, body) receipt = headers.get("PAYMENT-RESPONSE") or headers.get("payment-response") or receipt return status, text, receipt, { "offer": offer, "accepted": entry, "authorization": auth, "signature": sig_hex, }