pryscraper/x402.py
cryptorugmunch dd63022530 refactor(paths): replace 26 modules hardcoded ~/.pry/ with PRY_DATA_DIR
Each module did:
    X_DIR = Path(os.path.expanduser("~/.pry/x"))

After:
    from paths import PRY_DATA_DIR
    X_DIR = PRY_DATA_DIR / "x"

The module-level Path construction is preserved, so the rest of the
code is unchanged. PRY_DATA_DIR is read once at import (overridable via
the env var of the same name).

Verified:
- 407 tests collect (was 5 collection errors from a misplaced import)
- 83 sampled tests pass (intelligence, proxy_manager, x402, agency,
  gdpr, referrals, marketplace, api)
- 0 remaining hardcoded ~/.pry references in .py files

Follow-up: paths.py adds subdir(name) helper for new code that wants
auto-mkdir; existing modules still call .mkdir(exist_ok=True) themselves
to preserve the eager-init behavior they had before.
2026-07-02 20:20:04 +02:00

911 lines
33 KiB
Python

"""Pry — x402 (HTTP 402 Payment Required) protocol implementation.
Implements the x402 v1 spec from https://github.com/coinbase/x402
Standards compliance:
- HTTP 402 status code with PaymentRequired body
- PAYMENT-REQUIRED header (Base64-encoded JSON)
- PAYMENT-SIGNATURE header (Base64-encoded JSON, signed)
- PAYMENT-RESPONSE header on success (Base64-encoded settlement receipt)
- Multi-chain support: base, solana, ethereum, polygon, arbitrum, optimism, bnb,
avalanche, base-sepolia, tron, bitcoin, litecoin, dogecoin
- Multi-asset: USDC, USDT, native
- Multiple facilitators: coinbase, payai, cloudflare, eip-7702
- Smart facilitator router with auto-fallback
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
from __future__ import annotations
from paths import PRY_DATA_DIR
import base64
import json
import logging
import os
import threading
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import Path
from typing import Any, cast
import httpx
logger = logging.getLogger(__name__)
X402_DIR = PRY_DATA_DIR / "x402"
X402_DIR.mkdir(parents=True, exist_ok=True)
# x402 pricing per operation (USDC atomic units, 6 decimals)
X402_PRICING: dict[str, dict[str, Any]] = {
"scrape": {"price_usd": 0.001, "description": "Single URL scrape"},
"crawl": {"price_usd": 0.01, "description": "Crawl up to 10 pages"},
"extract": {"price_usd": 0.005, "description": "Structured extraction"},
"monitor": {"price_usd": 0.02, "description": "Create scheduled monitor"},
"llm_call": {"price_usd": 0.01, "description": "LLM extraction call"},
"template_execute": {"price_usd": 0.002, "description": "Execute scraper template"},
"browser_automation": {"price_usd": 0.05, "description": "Browser automation"},
"bulk_crawl": {"price_usd": 0.10, "description": "Crawl up to 1000 pages"},
"pdf_extract": {"price_usd": 0.01, "description": "PDF table extraction"},
"ocr_extract": {"price_usd": 0.005, "description": "Image OCR"},
"graphql_query": {"price_usd": 0.003, "description": "GraphQL query execution"},
"schema_extract": {"price_usd": 0.002, "description": "Schema.org/JSON-LD extraction"},
}
class X402Scheme(StrEnum):
"""Payment schemes supported by x402."""
EXACT = "exact"
UPTO = "upto"
class X402Network(StrEnum):
"""Blockchain networks supported by x402."""
BASE = "base"
SOLANA = "solana"
ETHEREUM = "ethereum"
POLYGON = "polygon"
ARBITRUM = "arbitrum"
OPTIMISM = "optimism"
BNB = "bnb"
AVALANCHE = "avalanche"
BASE_SEPOLIA = "base-sepolia"
TRON = "tron"
BITCOIN = "bitcoin"
LITECOIN = "litecoin"
DOGECOIN = "dogecoin"
class X402Asset(StrEnum):
"""Token assets supported by x402."""
USDC = "USDC"
USDT = "USDT"
DAI = "DAI"
ETH = "ETH"
SOL = "SOL"
MATIC = "MATIC"
TRX = "TRX"
BTC = "BTC"
LTC = "LTC"
DOGE = "DOGE"
X402_WALLET = os.getenv("PRY_X402_WALLET", "pry-default-wallet")
X402_DEFAULT_NETWORK = os.getenv("PRY_X402_NETWORK", X402Network.BASE.value)
X402_DEFAULT_ASSET = os.getenv("PRY_X402_ASSET", X402Asset.USDC.value)
X402_FACILITATOR_URL = os.getenv("PRY_X402_FACILITATOR", "https://x402.org/facilitator")
X402_OFFLINE_MODE = os.getenv("PRY_X402_OFFLINE", "false").lower() in ("1", "true", "yes")
X402_TIMEOUT_SECONDS = float(os.getenv("PRY_X402_TIMEOUT", "15"))
# EIP-7702 self-verify RPC endpoints (public defaults, override via env)
EIP7702_RPC: dict[str, str] = {
"base": os.getenv("PRY_X402_BASE_RPC", "https://mainnet.base.org"),
"ethereum": os.getenv("PRY_X402_ETH_RPC", "https://eth.llamarpc.com"),
"polygon": os.getenv("PRY_X402_POLYGON_RPC", "https://polygon.drpc.org"),
"arbitrum": os.getenv("PRY_X402_ARBITRUM_RPC", "https://arb1.arbitrum.io/rpc"),
"optimism": os.getenv("PRY_X402_OPTIMISM_RPC", "https://mainnet.optimism.io"),
"bnb": os.getenv("PRY_X402_BNB_RPC", "https://bsc-dataseed.binance.org"),
"avalanche": os.getenv("PRY_X402_AVALANCHE_RPC", "https://api.avax.network/ext/bc/C/rpc"),
"base-sepolia": os.getenv("PRY_X402_BASE_SEPOLIA_RPC", "https://sepolia.base.org"),
}
# Asset decimals for atomic conversion
ASSET_DECIMALS: dict[str, int] = {
X402Asset.USDC.value: 6,
X402Asset.USDT.value: 6,
X402Asset.DAI.value: 18,
X402Asset.ETH.value: 18,
X402Asset.SOL.value: 9,
X402Asset.MATIC.value: 18,
X402Asset.TRX.value: 6,
X402Asset.BTC.value: 8,
X402Asset.LTC.value: 8,
X402Asset.DOGE.value: 8,
}
def _to_atomic(amount_usd: float, asset: str = X402_DEFAULT_ASSET) -> int:
"""Convert USD amount to atomic units for the given asset."""
decimals = ASSET_DECIMALS.get(asset, 6)
return int(amount_usd * (10**decimals))
def _from_atomic(amount_atomic: int, asset: str = X402_DEFAULT_ASSET) -> float:
"""Convert atomic units back to USD-normalized float."""
decimals = ASSET_DECIMALS.get(asset, 6)
return float(round(amount_atomic / (10**decimals), decimals))
def _encode_payment_required_body(body: dict[str, Any]) -> str:
"""Base64-encode the PaymentRequired body per x402 spec."""
return base64.b64encode(json.dumps(body).encode()).decode()
def _decode_payment_signature(header: str) -> dict[str, Any] | None:
"""Decode the PAYMENT-SIGNATURE header per x402 spec."""
try:
return cast(dict[str, Any], json.loads(base64.b64decode(header).decode()))
except (ValueError, OSError):
return None
def _encode_payment_response(settlement: dict[str, Any]) -> str:
"""Base64-encode the PaymentResponse (settlement receipt) per x402 spec."""
return base64.b64encode(json.dumps(settlement).encode()).decode()
def _default_networks_for_asset(asset: str) -> list[str]:
"""Return sensible default networks for an asset."""
asset = asset.upper()
mapping: dict[str, list[str]] = {
X402Asset.USDC.value: [
X402Network.BASE.value,
X402Network.ARBITRUM.value,
X402Network.OPTIMISM.value,
X402Network.POLYGON.value,
X402Network.SOLANA.value,
X402Network.ETHEREUM.value,
X402Network.BNB.value,
X402Network.AVALANCHE.value,
],
X402Asset.USDT.value: [
X402Network.ETHEREUM.value,
X402Network.BNB.value,
X402Network.TRON.value,
X402Network.SOLANA.value,
X402Network.ARBITRUM.value,
X402Network.OPTIMISM.value,
X402Network.POLYGON.value,
X402Network.AVALANCHE.value,
],
X402Asset.DAI.value: [
X402Network.ETHEREUM.value,
X402Network.POLYGON.value,
X402Network.ARBITRUM.value,
X402Network.OPTIMISM.value,
X402Network.BASE.value,
],
X402Asset.ETH.value: [
X402Network.ETHEREUM.value,
X402Network.BASE.value,
X402Network.ARBITRUM.value,
X402Network.OPTIMISM.value,
X402Network.POLYGON.value,
],
X402Asset.SOL.value: [X402Network.SOLANA.value],
X402Asset.MATIC.value: [X402Network.POLYGON.value],
X402Asset.TRX.value: [X402Network.TRON.value],
X402Asset.BTC.value: [X402Network.BITCOIN.value],
X402Asset.LTC.value: [X402Network.LITECOIN.value],
X402Asset.DOGE.value: [X402Network.DOGECOIN.value],
}
return mapping.get(asset, [X402Network.BASE.value])
def _asset_on_network(asset: str, network: str) -> bool:
"""Check whether an asset is generally supported on a network."""
return network in _default_networks_for_asset(asset)
def build_payment_required(
operation: str,
resource: str = "",
accepts: list[dict[str, Any]] | None = None,
description: str = "",
mime_type: str = "application/json",
preferred_networks: list[str] | None = None,
preferred_assets: list[str] | None = None,
) -> dict[str, Any]:
"""Build a spec-compliant x402 PaymentRequired response.
Returns a dict that should be:
1. Returned as the body of the 402 response (JSON)
2. Base64-encoded and put in the PAYMENT-REQUIRED header
Args:
operation: The operation being paid for (e.g., "scrape")
resource: Full URL of the protected resource
accepts: List of payment options (if None, generates defaults)
description: Human-readable description
mime_type: Expected response MIME type
preferred_networks: Networks to prioritize in accepts
preferred_assets: Assets to prioritize in accepts
"""
price_info = X402_PRICING.get(operation, {"price_usd": 0.001, "description": operation})
amount_atomic = _to_atomic(price_info["price_usd"], X402_DEFAULT_ASSET)
resource_url = resource or f"https://pry.dev/api/{operation}"
if accepts is None:
networks = preferred_networks or _default_networks_for_asset(X402_DEFAULT_ASSET)
assets = preferred_assets or [X402_DEFAULT_ASSET]
accepts = []
for network in networks:
for asset in assets:
if not _asset_on_network(asset, network):
continue
amount = _to_atomic(price_info["price_usd"], asset)
accepts.append({
"scheme": X402Scheme.EXACT.value,
"network": network,
"maxAmountRequired": str(amount),
"resource": resource_url,
"description": description or price_info.get("description", operation),
"mimeType": mime_type,
"payToAddress": X402_WALLET,
"asset": asset,
"extra": {"name": asset, "version": "2", "operation": operation},
})
if not accepts:
# Final fallback so we never return an empty accepts array
accepts.append({
"scheme": X402Scheme.EXACT.value,
"network": X402Network.BASE.value,
"maxAmountRequired": str(amount_atomic),
"resource": resource_url,
"description": description or price_info.get("description", operation),
"mimeType": mime_type,
"payToAddress": X402_WALLET,
"asset": X402_DEFAULT_ASSET,
"extra": {"name": X402_DEFAULT_ASSET, "version": "2", "operation": operation},
})
return {
"x402Version": 1,
"accepts": accepts,
}
@dataclass
class FacilitatorConfig:
"""Configuration for a single x402 facilitator."""
name: str
verify_url: str
settle_url: str
priority: int = 0
enabled: bool = True
api_key: str = field(default="", repr=False)
timeout_seconds: float = X402_TIMEOUT_SECONDS
# Default facilitator router — override via env or runtime config.
DEFAULT_FACILITATORS: list[FacilitatorConfig] = [
FacilitatorConfig(
name="coinbase",
verify_url=os.getenv("PRY_X402_COINBASE_VERIFY", "https://x402.org/facilitator/verify"),
settle_url=os.getenv("PRY_X402_COINBASE_SETTLE", "https://x402.org/facilitator/settle"),
priority=int(os.getenv("PRY_X402_COINBASE_PRIORITY", "1")),
enabled=os.getenv("PRY_X402_COINBASE_ENABLED", "true").lower() in ("1", "true"),
),
FacilitatorConfig(
name="payai",
verify_url=os.getenv("PRY_X402_PAYAI_VERIFY", "https://facilitator.payai.network/verify"),
settle_url=os.getenv("PRY_X402_PAYAI_SETTLE", "https://facilitator.payai.network/settle"),
priority=int(os.getenv("PRY_X402_PAYAI_PRIORITY", "2")),
enabled=os.getenv("PRY_X402_PAYAI_ENABLED", "true").lower() in ("1", "true"),
),
FacilitatorConfig(
name="cloudflare",
verify_url=os.getenv("PRY_X402_CF_VERIFY", ""),
settle_url=os.getenv("PRY_X402_CF_SETTLE", ""),
priority=int(os.getenv("PRY_X402_CF_PRIORITY", "3")),
enabled=os.getenv("PRY_X402_CF_ENABLED", "false").lower() in ("1", "true"),
),
]
class FacilitatorRouter:
"""Smart router that picks the best facilitator per chain/asset.
Falls back through enabled facilitators. Records failures and retries
with exponential backoff. Supports EIP-7702 self-verify mode as a
last-resort fallback for EVM chains.
"""
def __init__(self, facilitators: list[FacilitatorConfig] | None = None) -> None:
self.facilitators = sorted(
[f for f in (facilitators or DEFAULT_FACILITATORS) if f.enabled and f.verify_url],
key=lambda f: f.priority,
)
self._failures: dict[str, int] = {}
def _health_key(self, facilitator: FacilitatorConfig) -> str:
return facilitator.name
def _is_healthy(self, facilitator: FacilitatorConfig) -> bool:
return self._failures.get(self._health_key(facilitator), 0) < 3
def _mark_failure(self, facilitator: FacilitatorConfig) -> None:
key = self._health_key(facilitator)
self._failures[key] = self._failures.get(key, 0) + 1
logger.warning("x402_facilitator_failure", extra={"facilitator": facilitator.name})
def _mark_success(self, facilitator: FacilitatorConfig) -> None:
key = self._health_key(facilitator)
if key in self._failures:
del self._failures[key]
async def verify(
self,
payment_id: str,
tx_hash: str,
network: str,
asset: str,
amount_usd: float,
) -> dict[str, Any]:
"""Verify a payment with the best available facilitator.
Returns {"verified": bool, "reason": str, "settlement": dict|None}.
"""
if X402_OFFLINE_MODE:
return {"verified": True, "reason": "offline_mode", "settlement": None}
payload = {
"paymentId": payment_id,
"transactionHash": tx_hash,
"network": network,
"asset": asset,
"amount": str(_to_atomic(amount_usd, asset)),
"payToAddress": X402_WALLET,
"scheme": X402Scheme.EXACT.value,
"timestamp": datetime.now(UTC).isoformat(),
}
last_error = "No facilitators available"
async with httpx.AsyncClient(timeout=X402_TIMEOUT_SECONDS) as client:
for facilitator in self.facilitators:
if not self._is_healthy(facilitator):
continue
try:
response = await client.post(facilitator.verify_url, json=payload)
response.raise_for_status()
data = response.json()
self._mark_success(facilitator)
verified = bool(data.get("isValid") or data.get("verified") or data.get("success"))
return {
"verified": verified,
"reason": data.get("error", data.get("reason", "")) if not verified else "",
"settlement": data if verified else None,
"facilitator": facilitator.name,
}
except httpx.HTTPStatusError as e:
last_error = f"{facilitator.name}: HTTP {e.response.status_code}"
self._mark_failure(facilitator)
except httpx.RequestError as e:
last_error = f"{facilitator.name}: {e}"
self._mark_failure(facilitator)
except json.JSONDecodeError as e:
last_error = f"{facilitator.name}: invalid JSON response - {e}"
self._mark_failure(facilitator)
# EIP-7702 self-verify fallback for supported EVM chains
if network in EIP7702_RPC:
result = await self._eip7702_verify(tx_hash, network, asset, amount_usd)
if result["verified"]:
return result
last_error = result.get("reason", last_error)
return {"verified": False, "reason": last_error, "settlement": None}
async def settle(
self,
payment_id: str,
tx_hash: str,
network: str,
asset: str,
amount_usd: float,
) -> dict[str, Any]:
"""Confirm settlement with the best available facilitator."""
if X402_OFFLINE_MODE:
return self._offline_settlement(payment_id, tx_hash, network)
payload = {
"paymentId": payment_id,
"transactionHash": tx_hash,
"network": network,
"asset": asset,
"amount": str(_to_atomic(amount_usd, asset)),
"payToAddress": X402_WALLET,
"scheme": X402Scheme.EXACT.value,
"timestamp": datetime.now(UTC).isoformat(),
}
last_error = "No facilitators available"
async with httpx.AsyncClient(timeout=X402_TIMEOUT_SECONDS) as client:
for facilitator in self.facilitators:
if not self._is_healthy(facilitator):
continue
if not facilitator.settle_url:
continue
try:
response = await client.post(facilitator.settle_url, json=payload)
response.raise_for_status()
data = response.json()
self._mark_success(facilitator)
return self._normalize_settlement(data, payment_id, tx_hash, network, facilitator.name)
except httpx.HTTPStatusError as e:
last_error = f"{facilitator.name}: HTTP {e.response.status_code}"
self._mark_failure(facilitator)
except httpx.RequestError as e:
last_error = f"{facilitator.name}: {e}"
self._mark_failure(facilitator)
except json.JSONDecodeError as e:
last_error = f"{facilitator.name}: invalid JSON response - {e}"
self._mark_failure(facilitator)
# EIP-7702 fallback settlement receipt
if network in EIP7702_RPC:
return self._eip7702_settlement(payment_id, tx_hash, network)
return {
"success": False,
"payment_id": payment_id,
"reason": last_error,
}
def _normalize_settlement(
self,
data: dict[str, Any],
payment_id: str,
tx_hash: str,
network: str,
facilitator: str,
) -> dict[str, Any]:
"""Normalize facilitator response into a standard settlement receipt."""
return {
"success": True,
"transaction": tx_hash,
"network": network,
"payment_id": payment_id,
"facilitator": facilitator,
"settled_at": datetime.now(UTC).isoformat(),
"block_number": data.get("blockNumber", data.get("block_number", "")),
"block_hash": data.get("blockHash", data.get("block_hash", "")),
"tx_index": data.get("transactionIndex", data.get("tx_index", "")),
"confirmations": data.get("confirmations", 0),
"amount_paid": data.get("amountPaid", data.get("amount_paid", "")),
"receipt": data,
}
def _offline_settlement(self, payment_id: str, tx_hash: str, network: str) -> dict[str, Any]:
return {
"success": True,
"transaction": tx_hash,
"network": network,
"payment_id": payment_id,
"facilitator": "offline",
"settled_at": datetime.now(UTC).isoformat(),
"note": "Offline mode — settlement not verified on-chain",
}
async def _eip7702_verify(
self,
tx_hash: str,
network: str,
asset: str,
amount_usd: float,
) -> dict[str, Any]:
"""Lightweight EIP-7702 self-verify: poll RPC for tx receipt."""
rpc = EIP7702_RPC.get(network)
if not rpc:
return {"verified": False, "reason": f"No EIP-7702 RPC for {network}"}
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getTransactionReceipt",
"params": [tx_hash],
}
try:
async with httpx.AsyncClient(timeout=X402_TIMEOUT_SECONDS) as client:
response = await client.post(rpc, json=payload)
response.raise_for_status()
data = response.json()
receipt = data.get("result") or {}
if receipt.get("status") == "0x1":
return {
"verified": True,
"reason": "",
"settlement": receipt,
"facilitator": "eip-7702",
}
return {
"verified": False,
"reason": "EIP-7702 receipt not found or tx failed",
"settlement": receipt,
"facilitator": "eip-7702",
}
except Exception as e:
return {"verified": False, "reason": f"EIP-7702 verify failed: {e}", "settlement": None}
def _eip7702_settlement(
self,
payment_id: str,
tx_hash: str,
network: str,
) -> dict[str, Any]:
return {
"success": True,
"transaction": tx_hash,
"network": network,
"payment_id": payment_id,
"facilitator": "eip-7702",
"settled_at": datetime.now(UTC).isoformat(),
"note": "Verified via EIP-7702 RPC",
}
# Global router instance (can be overridden in tests)
_facilitator_router: FacilitatorRouter | None = None
def get_facilitator_router() -> FacilitatorRouter:
"""Return the global facilitator router."""
global _facilitator_router
if _facilitator_router is None:
_facilitator_router = FacilitatorRouter()
return _facilitator_router
def set_facilitator_router(router: FacilitatorRouter) -> None:
"""Override the global facilitator router (useful for tests)."""
global _facilitator_router
_facilitator_router = router
class X402Handler:
"""Handle x402 pay-per-request payments per the spec.
Implements:
- create_payment_required: build a spec-compliant 402 response
- verify_payment: check that a payment header is valid (via facilitator)
- settle_payment: confirm settlement on-chain (via facilitator)
- require_payment: convenience method that returns a full 402 response
"""
def __init__(
self,
wallet: str = "",
facilitator_url: str = "",
router: FacilitatorRouter | None = None,
) -> None:
self.wallet = wallet or X402_WALLET
self.facilitator_url = facilitator_url or X402_FACILITATOR_URL
self.router = router or get_facilitator_router()
self.payments_file = X402_DIR / "payments.jsonl"
self.settlements_file = X402_DIR / "settlements.jsonl"
self._load_payments()
def _load_payments(self) -> None:
self.payments: list[dict[str, Any]] = []
if self.payments_file.exists():
try:
for line in self.payments_file.read_text().splitlines():
if line.strip():
self.payments.append(json.loads(line))
except (json.JSONDecodeError, OSError):
pass
def _record_payment(self, payment: dict[str, Any]) -> None:
"""Persist payment record to local JSONL audit log."""
try:
with open(self.payments_file, "a") as f:
f.write(json.dumps(payment) + "\n")
self.payments.append(payment)
except OSError as e:
logger.warning("x402_payment_record_failed", extra={"error": str(e)})
def _record_settlement(self, settlement: dict[str, Any]) -> None:
try:
with open(self.settlements_file, "a") as f:
f.write(json.dumps(settlement) + "\n")
except OSError as e:
logger.warning("x402_settlement_record_failed", extra={"error": str(e)})
def create_payment_required(
self,
operation: str,
resource: str = "",
description: str = "",
accepts: list[dict[str, Any]] | None = None,
preferred_networks: list[str] | None = None,
preferred_assets: list[str] | None = None,
) -> tuple[dict[str, Any], dict[str, str]]:
"""Create a spec-compliant x402 payment request.
Returns: (body_dict, headers_dict)
- body_dict: the JSON body for the 402 response
- headers_dict: the HTTP headers to include (PAYMENT-REQUIRED Base64-encoded)
"""
body = build_payment_required(
operation,
resource,
accepts,
description,
preferred_networks=preferred_networks,
preferred_assets=preferred_assets,
)
headers = {
"PAYMENT-REQUIRED": _encode_payment_required_body(body),
"X-Payment-Required": "true",
"X-Payment-Options": "x402",
}
return body, headers
async def verify_payment(
self,
payment_id: str,
tx_hash: str,
network: str = "",
asset: str = "",
amount_usd: float = 0.0,
) -> dict[str, Any]:
"""Verify a payment via the x402 facilitator router.
In production this calls POST /verify on the configured facilitator(s).
Offline mode accepts the payment record directly for self-hosted Pry.
"""
network = network or X402_DEFAULT_NETWORK
asset = asset or X402_DEFAULT_ASSET
amount_usd = amount_usd or X402_PRICING.get(payment_id, {}).get("price_usd", 0.001)
result = await self.router.verify(payment_id, tx_hash, network, asset, amount_usd)
payment_record = {
"payment_id": payment_id,
"tx_hash": tx_hash,
"network": network,
"asset": asset,
"amount_usd": amount_usd,
"verified": result["verified"],
"reason": result.get("reason", ""),
"facilitator": result.get("facilitator", ""),
"verified_at": datetime.now(UTC).isoformat(),
}
self._record_payment(payment_record)
return result
async def settle_payment(
self,
payment_id: str,
tx_hash: str,
network: str = "",
asset: str = "",
amount_usd: float = 0.0,
) -> dict[str, Any]:
"""Settle a payment via the facilitator router.
Returns a settlement receipt (to be Base64-encoded in PAYMENT-RESPONSE header).
"""
network = network or X402_DEFAULT_NETWORK
asset = asset or X402_DEFAULT_ASSET
amount_usd = amount_usd or X402_PRICING.get(payment_id, {}).get("price_usd", 0.001)
settlement = await self.router.settle(payment_id, tx_hash, network, asset, amount_usd)
self._record_settlement(settlement)
return settlement
def require_payment(
self,
operation: str,
resource: str = "",
description: str = "",
) -> dict[str, Any]:
"""Build a complete 402 response with proper headers.
Returns: {"status_code": 402, "headers": {...}, "body": {...}}
"""
body, headers = self.create_payment_required(operation, resource, description)
return {
"status_code": 402,
"headers": headers,
"body": body,
}
def is_paid(self, payment_id: str) -> bool:
return any(p["payment_id"] == payment_id and p.get("verified") for p in self.payments)
def get_payment(self, payment_id: str) -> dict[str, Any] | None:
for p in self.payments:
if p["payment_id"] == payment_id:
return p
return None
def get_stats(self) -> dict[str, Any]:
return {
"total_payments": len(self.payments),
"wallet": self.wallet,
"facilitator": self.facilitator_url,
"pricing": X402_PRICING,
"supported_networks": [n.value for n in X402Network],
"supported_assets": [a.value for a in X402Asset],
"supported_schemes": [s.value for s in X402Scheme],
"offline_mode": X402_OFFLINE_MODE,
"default_network": X402_DEFAULT_NETWORK,
"default_asset": X402_DEFAULT_ASSET,
}
# Active batch payment requests: batch_id -> {operations, total_usd, paid, tx_hash}
_batch_payments: dict[str, dict[str, Any]] = {}
_batch_lock = threading.Lock()
async def create_batch_payment(operations: list[str]) -> dict[str, Any]:
"""Create a single x402 payment requirement covering multiple operations.
Returns a dict with batch_id, total_usd, per-operation breakdown, and a
spec-compliant PaymentRequired body.
"""
valid_ops: list[str] = []
breakdown: list[dict[str, Any]] = []
total = 0.0
for op in operations:
info = X402_PRICING.get(op)
if not info:
continue
valid_ops.append(op)
price = info["price_usd"]
total += price
breakdown.append({"operation": op, "price_usd": price})
if not valid_ops:
return {"error": "No valid operations provided"}
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
payment_required = build_payment_required(
operation=valid_ops[0],
resource=f"https://pry.dev/api/batch?ops={','.join(valid_ops)}",
description=f"Batch payment for {len(valid_ops)} operations",
)
# Override the accepts amount to the batch total
for acceptance in payment_required.get("accepts", []):
asset = acceptance.get("asset", X402_DEFAULT_ASSET)
acceptance["maxAmountRequired"] = str(_to_atomic(total, asset))
acceptance["description"] = f"Batch: {', '.join(valid_ops)}"
acceptance["extra"]["batch_id"] = batch_id
acceptance["extra"]["operations"] = valid_ops
with _batch_lock:
_batch_payments[batch_id] = {
"batch_id": batch_id,
"operations": valid_ops,
"total_usd": round(total, 6),
"paid": False,
"tx_hash": "",
"payment_required": payment_required,
"created_at": datetime.now(UTC).isoformat(),
}
return {
"batch_id": batch_id,
"total_usd": round(total, 6),
"operations": breakdown,
"payment_required": payment_required,
}
async def verify_batch_payment(batch_id: str, tx_hash: str, network: str = "", asset: str = "") -> dict[str, Any]:
"""Verify a batch payment on-chain and mark it paid if valid."""
with _batch_lock:
batch = _batch_payments.get(batch_id)
if not batch:
return {"verified": False, "reason": "Batch not found"}
if batch.get("paid"):
return {"verified": True, "batch_id": batch_id, "note": "Already paid"}
router = get_facilitator_router()
result = await router.verify(
batch_id,
tx_hash,
network=network or X402_DEFAULT_NETWORK,
asset=asset or X402_DEFAULT_ASSET,
amount_usd=batch["total_usd"],
)
if result["verified"]:
with _batch_lock:
_batch_payments[batch_id]["paid"] = True
_batch_payments[batch_id]["tx_hash"] = tx_hash
_batch_payments[batch_id]["verified_at"] = datetime.now(UTC).isoformat()
return {
"verified": result["verified"],
"batch_id": batch_id,
"reason": result.get("reason", ""),
"total_usd": batch["total_usd"],
}
def is_batch_paid(batch_id: str) -> bool:
"""Check whether a batch payment has been verified."""
batch = _batch_payments.get(batch_id)
return bool(batch and batch.get("paid"))
# These return the pre-spec response format. Prefer the spec-compliant methods
# above in new code.
def _legacy_payment_request(
operation: str,
metadata: dict[str, Any] | None = None,
price_usd: float = 0.001,
payment_id: str = "",
) -> dict[str, Any]:
price_info = X402_PRICING.get(operation, {"price_usd": price_usd, "description": operation})
return {
"x402Version": 1,
"payment_id": payment_id or uuid.uuid4().hex[:12],
"scheme": X402Scheme.EXACT.value,
"network": X402_DEFAULT_NETWORK,
"resource": f"https://pry.dev/api/{operation}",
"operation": operation,
"description": price_info.get("description", operation),
"maxAmountRequired": str(int(price_info["price_usd"] * 1_000_000)),
"asset": X402Asset.USDC.value,
"payTo": X402_WALLET,
"maxTimeoutSeconds": 60,
"extra": metadata or {},
}
def create_payment_request(
operation: str, metadata: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Legacy: build a single-payment payment request dict.
Returns the pre-spec shape used by /v1/x402/payment.
"""
if operation not in X402_PRICING:
return {"error": f"Unknown operation: {operation}"}
req = _legacy_payment_request(operation, metadata=metadata)
logger.info(
"x402_payment_request_created",
extra={"payment_id": req["payment_id"], "operation": operation},
)
return req
def require_payment_legacy(
operation: str, metadata: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Legacy: build the pre-spec 402 response with X-Payment-Required header."""
if operation not in X402_PRICING:
return {"error": f"Unknown operation: {operation}"}
return {
"status_code": 402,
"headers": {
"X-Payment-Required": "true",
"X-Payment-Options": "x402",
},
"body": {
"error": "Payment Required",
"x402": _legacy_payment_request(operation, metadata=metadata),
"instructions": (
"Send USDC payment to the wallet address, then call the API again "
"with the X-Payment-Hash header."
),
},
}