uvd-x402-sdk
Python SDK for integrating x402 cryptocurrency payments via the Ultravioleta DAO facilitator.
Accept gasless stablecoin payments across 25 blockchain networks with a single integration. The SDK handles signature verification, on-chain settlement, and all the complexity of multi-chain payments.
New in v0.26.0: Robinhood Chain support (robinhood / robinhood-testnet, chain IDs 4663 / 46630), settling in Paxos USDG (EIP-712 domain Global Dollar version 1, sent via extra since version() reverts on-chain).
Features
- 25 Networks: EVM chains (15 including Robinhood, Scroll, SKALE), SVM chains (Solana, Fogo), NEAR, Stellar, Algorand, Sui, and XRPL (native XRP)
- 6 Stablecoins: USDC, EURC, AUSD, PYUSD, USDT, USDG (EVM chains); XRPL settles in native XRP
- x402 v1 & v2: Full support for both protocol versions with auto-detection
- Framework Integrations: Flask, FastAPI, Django, AWS Lambda
- Gasless Payments: Users sign EIP-712/EIP-3009 authorizations, facilitator pays all network fees
- Simple API: Decorators and middleware for quick integration
- Type Safety: Full Pydantic models and type hints
- Extensible: Register custom networks and tokens easily
- ERC-8004 Trustless Agents: On-chain reputation and identity for AI agents (21 networks: 19 EVM + Solana + Solana-devnet)
- Escrow & Refunds: Hold payments in escrow with dispute resolution (11 EVM chains + SKALE via CREATE3)
- Commerce Scheme: Supports
"exact","escrow", and"commerce"schemes (facilitator v1.43.0+) - Server-Side Signing:
connect_with_private_key()for backend EIP-3009 signing without browser wallet /acceptsNegotiation: Discover facilitator capabilities before constructing payments- Bazaar Discovery: Register and discover paid resources across the x402 network
- x402 v2 Envelopes: the client picks v1 or v2 from the wire (
x402_version="auto");build_verify_request_v2/build_settle_request_v2remain for hand-built bodies - Live Traffic Stream: Subscribe to
GET /events(SSE) for settlements as they happen — lossy live hint, not a ledger - Facilitator Info: Query version, supported networks, blacklist, and health
- WalletAdapter: Abstract protocol for wallet signing (EnvKeyAdapter, OWSWalletAdapter)
- ERC-8128 Signed HTTP Requests: RFC 9421 request signing with any WalletAdapter — authenticate against wallet-signed APIs like Execution Market
- Escrow Pre-Auth Builder:
build_escrow_pre_auth()/compute_escrow_nonce()— sign the ADR-002 sign-on-assignment escrow lock (X-Payment-Authheader) with any WalletAdapter, no web3 required - Signed escrow lifecycle orders:
build_lifecycle_auth()— sign the EIP-712 order that entitles arelease/refundInEscrow, and pass alifecycle_signertorelease_via_facilitator()/refund_via_facilitator(), or alifecycle_authwhen someone else already signed it
Quick Start (5 Lines)
from decimal import Decimal
from uvd_x402_sdk import X402Client
client = X402Client(recipient_address="0xYourWallet...")
result = client.process_payment(request.headers["X-PAYMENT"], Decimal("10.00"))
print(f"Paid by {result.payer_address}, tx: {result.transaction_hash}")
Complete Usage Example
Here's a complete example showing the full x402 payment flow - returning a 402 response when no payment is provided, and processing the payment when it arrives:
from decimal import Decimal
from uvd_x402_sdk import (
X402Client,
X402Config,
create_402_response,
PaymentRequiredError,
)
# 1. Configure the client with your recipient addresses
config = X402Config(
recipient_evm="0xYourEVMWallet...", # For Base, Ethereum, etc.
recipient_solana="YourSolanaAddress...", # For Solana, Fogo
recipient_near="your-account.near", # For NEAR
recipient_stellar="G...YourStellarAddress", # For Stellar
recipient_algorand="YOUR_ALGO_ADDRESS...", # For Algorand
recipient_sui="0xYourSuiAddress...", # For Sui
recipient_xrpl="r...YourXRPLAddress", # For XRP Ledger (native XRP)
)
client = X402Client(config=config)
def handle_api_request(request):
"""
Handle an API request that requires payment.
"""
price = Decimal("1.00") # $1.00 USD
# Check if payment header exists
x_payment = request.headers.get("X-PAYMENT")
if not x_payment:
# No payment provided - return 402 Payment Required
return {
"status": 402,
"headers": {
"Content-Type": "application/json",
},
"body": create_402_response(
amount_usd=price,
config=config,
resource="/api/premium",
description="Premium API access",
)
}
# Payment provided - verify and settle
try:
result = client.process_payment(x_payment, price)
# Payment successful!
return {
"status": 200,
"body": {
"success": True,
"message": "Payment verified and settled!",
"payer": result.payer_address,
"network": result.network,
"transaction_hash": result.transaction_hash,
"amount_paid": str(result.amount),
}
}
except PaymentRequiredError as e:
# Payment verification failed
return {
"status": 402,
"body": {"error": str(e)}
}
# The 402 response includes payment options for all configured networks:
# {
# "x402Version": 1,
# "accepts": [
# {"network": "base", "asset": "0x833589fCD...", "amount": "1000000", "payTo": "0xYour..."},
# {"network": "solana", "asset": "EPjFWdd5...", "amount": "1000000", "payTo": "Your..."},
# {"network": "near", "asset": "17208628...", "amount": "1000000", "payTo": "your.near"},
# {"network": "stellar", "asset": "CCW67Q...", "amount": "10000000", "payTo": "G..."},
# {"network": "algorand", "asset": "31566704", "amount": "1000000", "payTo": "YOUR..."},
# {"network": "sui", "asset": "0xdba346...", "amount": "1000000", "payTo": "0xYour..."},
# ],
# "payTo": "0xYour...",
# "maxAmountRequired": "1000000",
# "resource": "/api/premium",
# "description": "Premium API access"
# }
Using the Decorator (Simpler)
For even simpler integration, use the @require_payment decorator:
from decimal import Decimal
from uvd_x402_sdk import require_payment, configure_x402
# Configure once at startup
configure_x402(
recipient_address="0xYourEVMWallet...",
recipient_solana="YourSolanaAddress...",
)
@require_payment(amount_usd=Decimal("1.00"))
def premium_endpoint(payment_result):
"""
This function only runs if payment is verified.
Returns 402 automatically if no valid payment.
"""
return {
"data": "Premium content here!",
"paid_by": payment_result.payer_address,
"tx": payment_result.transaction_hash,
}
Supported Networks
| Network | Type | Chain ID | CAIP-2 | Status |
|---|---|---|---|---|
| Base | EVM | 8453 | eip155:8453 |
Active |
| Ethereum | EVM | 1 | eip155:1 |
Active |
| Polygon | EVM | 137 | eip155:137 |
Active |
| Arbitrum | EVM | 42161 | eip155:42161 |
Active |
| Optimism | EVM | 10 | eip155:10 |
Active |
| Avalanche | EVM | 43114 | eip155:43114 |
Active |
| Celo | EVM | 42220 | eip155:42220 |
Active |
| HyperEVM | EVM | 999 | eip155:999 |
Active |
| Unichain | EVM | 130 | eip155:130 |
Active |
| Monad | EVM | 143 | eip155:143 |
Active |
| Scroll | EVM | 534352 | eip155:534352 |
Active |
| SKALE | EVM | 1187947933 | eip155:1187947933 |
Active |
| SKALE Testnet | EVM | 324705682 | eip155:324705682 |
Active |
| Robinhood | EVM | 4663 | eip155:4663 |
Active |
| Robinhood Testnet | EVM | 46630 | eip155:46630 |
Active |
| Solana | SVM | - | solana:5eykt... |
Active |
| Fogo | SVM | - | solana:fogo |
Active |
| NEAR | NEAR | - | near:mainnet |
Active |
| Stellar | Stellar | - | stellar:pubnet |
Active |
| Algorand | Algorand | - | algorand:mainnet |
Active |
| Algorand Testnet | Algorand | - | algorand:testnet |
Active |
| Sui | Sui | - | sui:mainnet |
Active |
| Sui Testnet | Sui | - | sui:testnet |
Active |
| XRP Ledger | XRPL | - | (no CAIP-2) xrpl |
Active |
| XRP Ledger Testnet | XRPL | - | (no CAIP-2) xrpl-testnet |
Active |
Supported Tokens
| Token | Networks | Decimals |
|---|---|---|
| USDC | All networks except Robinhood | 6 |
| EURC | Ethereum, Base, Avalanche | 6 |
| AUSD | Ethereum, Arbitrum, Avalanche, Polygon, Monad, Sui | 6 |
| PYUSD | Ethereum | 6 |
| USDT | Ethereum, Arbitrum, Optimism, Avalanche, Polygon | 6 |
| USDG | Robinhood, Robinhood Testnet | 6 |
Robinhood Chain settles in Paxos USDG, not USDC. USDG's on-chain
version()getter reverts, so clients MUST send the EIP-712 domain{"name": "Global Dollar", "version": "1"}inPaymentRequirements.extra. The SDK carries this automatically for therobinhood/robinhood-testnetnetworks.
Installation
# Core SDK (minimal dependencies)
pip install uvd-x402-sdk
# With wallet signing support (EIP-3009)
pip install uvd-x402-sdk[wallet] # WalletAdapter + EnvKeyAdapter
pip install uvd-x402-sdk[signer] # Alias for wallet (backward compat)
# With framework support
pip install uvd-x402-sdk[flask] # Flask integration
pip install uvd-x402-sdk[fastapi] # FastAPI/Starlette integration
pip install uvd-x402-sdk[django] # Django integration
pip install uvd-x402-sdk[aws] # AWS Lambda helpers
pip install uvd-x402-sdk[algorand] # Algorand atomic group helpers
# All integrations
pip install uvd-x402-sdk[all]
Framework Examples
Flask
from decimal import Decimal
from flask import Flask, g, jsonify
from uvd_x402_sdk.integrations import FlaskX402
app = Flask(__name__)
x402 = FlaskX402(
app,
recipient_address="0xYourEVMWallet...",
recipient_solana="YourSolanaAddress...",
recipient_near="your-account.near",
recipient_stellar="G...YourStellarAddress",
)
@app.route("/api/premium")
@x402.require_payment(amount_usd=Decimal("5.00"))
def premium():
return jsonify({
"message": "Premium content!",
"payer": g.payment_result.payer_address,
"tx": g.payment_result.transaction_hash,
"network": g.payment_result.network,
})
@app.route("/api/basic")
@x402.require_payment(amount_usd=Decimal("0.10"))
def basic():
return jsonify({"data": "Basic tier data"})
if __name__ == "__main__":
app.run(debug=True)
FastAPI
from decimal import Decimal
from fastapi import FastAPI, Depends
from uvd_x402_sdk.config import X402Config
from uvd_x402_sdk.models import PaymentResult
from uvd_x402_sdk.integrations import FastAPIX402
app = FastAPI()
x402 = FastAPIX402(
app,
recipient_address="0xYourEVMWallet...",
recipient_solana="YourSolanaAddress...",
recipient_near="your-account.near",
recipient_stellar="G...YourStellarAddress",
)
@app.get("/api/premium")
async def premium(
payment: PaymentResult = Depends(x402.require_payment(amount_usd="5.00"))
):
return {
"message": "Premium content!",
"payer": payment.payer_address,
"network": payment.network,
}
@app.post("/api/generate")
async def generate(
body: dict,
payment: PaymentResult = Depends(x402.require_payment(amount_usd="1.00"))
):
# Dynamic processing based on request
return {"result": "generated", "payer": payment.payer_address}
Django
# settings.py
X402_FACILITATOR_URL = "https://facilitator.ultravioletadao.xyz"
X402_RECIPIENT_EVM = "0xYourEVMWallet..."
X402_RECIPIENT_SOLANA = "YourSolanaAddress..."
X402_RECIPIENT_NEAR = "your-account.near"
X402_RECIPIENT_STELLAR = "G...YourStellarAddress"
X402_PROTECTED_PATHS = {
"/api/premium/": "5.00",
"/api/basic/": "1.00",
}
MIDDLEWARE = [
# ...other middleware...
"uvd_x402_sdk.integrations.django_integration.DjangoX402Middleware",
]
# views.py
from django.http import JsonResponse
from uvd_x402_sdk.integrations import django_x402_required
@django_x402_required(amount_usd="5.00")
def premium_view(request):
payment = request.payment_result
return JsonResponse({
"message": "Premium content!",
"payer": payment.payer_address,
})
AWS Lambda
import json
from decimal import Decimal
from uvd_x402_sdk.config import X402Config
from uvd_x402_sdk.integrations import LambdaX402
config = X402Config(
recipient_evm="0xYourEVMWallet...",
recipient_solana="YourSolanaAddress...",
recipient_near="your-account.near",
recipient_stellar="G...YourStellarAddress",
)
x402 = LambdaX402(config=config)
def handler(event, context):
# Calculate price based on request
body = json.loads(event.get("body", "{}"))
quantity = body.get("quantity", 1)
price = Decimal(str(quantity * 0.01))
# Process payment or return 402
result = x402.process_or_require(event, price)
# If 402 response, return it
if isinstance(result, dict) and "statusCode" in result:
return result
# Payment verified!
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"success": True,
"payer": result.payer_address,
"tx": result.transaction_hash,
"network": result.network,
"quantity": quantity,
})
}
Network-Specific Examples
EVM Chains (Base, Ethereum, Polygon, etc.)
EVM chains use ERC-3009 TransferWithAuthorization with EIP-712 signatures.
from uvd_x402_sdk import X402Client, X402Config
# Accept payments on Base and Ethereum only
config = X402Config(
recipient_evm="0xYourEVMWallet...",
supported_networks=["base", "ethereum"],
)
client = X402Client(config=config)
result = client.process_payment(x_payment_header, Decimal("10.00"))
# The payload contains EIP-712 signature + authorization
payload = client.extract_payload(x_payment_header)
evm_data = payload.get_evm_payload()
print(f"From: {evm_data.authorization.from_address}")
print(f"To: {evm_data.authorization.to}")
print(f"Value: {evm_data.authorization.value}")
Solana & Fogo (SVM Chains)
SVM chains use partially-signed VersionedTransactions with SPL token transfers.
from uvd_x402_sdk import X402Client, X402Config
# Accept payments on Solana and Fogo
config = X402Config(
recipient_solana="YourSolanaAddress...",
supported_networks=["solana", "fogo"],
)
client = X402Client(config=config)
result = client.process_payment(x_payment_header, Decimal("5.00"))
# The payload contains a base64-encoded VersionedTransaction
payload = client.extract_payload(x_payment_header)
svm_data = payload.get_svm_payload()
print(f"Transaction: {svm_data.transaction[:50]}...")
# Fogo has ultra-fast finality (~400ms)
if result.network == "fogo":
print("Payment confirmed in ~400ms!")
Stellar
Stellar uses Soroban Authorization Entries with fee-bump transactions.
from uvd_x402_sdk import X402Client, X402Config
config = X402Config(
recipient_stellar="G...YourStellarAddress",
supported_networks=["stellar"],
)
client = X402Client(config=config)
result = client.process_payment(x_payment_header, Decimal("1.00"))
# Stellar uses 7 decimals (stroops)
payload = client.extract_payload(x_payment_header)
stellar_data = payload.get_stellar_payload()
print(f"From: {stellar_data.from_address}")
print(f"Amount (stroops): {stellar_data.amount}")
print(f"Token Contract: {stellar_data.tokenContract}")
NEAR Protocol
NEAR uses NEP-366 meta-transactions with Borsh serialization.
from uvd_x402_sdk import X402Client, X402Config
config = X402Config(
recipient_near="your-recipient.near",
supported_networks=["near"],
)
client = X402Client(config=config)
result = client.process_payment(x_payment_header, Decimal("2.00"))
# NEAR payload contains a SignedDelegateAction
payload = client.extract_payload(x_payment_header)
near_data = payload.get_near_payload()
print(f"SignedDelegateAction: {near_data.signedDelegateAction[:50]}...")
# Validate NEAR payload structure
from uvd_x402_sdk.networks.near import validate_near_payload
validate_near_payload(payload.payload) # Raises ValueError if invalid
Algorand
Algorand uses atomic groups with ASA (Algorand Standard Assets) transfers.
from uvd_x402_sdk import X402Client, X402Config
config = X402Config(
recipient_algorand="NCDSNUQ2QLXDMJXRALAW4CRUSSKG4IS37MVOFDQQPC45SE4EBZO42U6ZX4",
supported_networks=["algorand"],
)
client = X402Client(config=config)
result = client.process_payment(x_payment_header, Decimal("1.00"))
# Algorand uses atomic groups: [fee_tx, payment_tx]
from uvd_x402_sdk.networks.algorand import (
validate_algorand_payload,
get_algorand_fee_payer,
build_atomic_group,
)
# Get the facilitator fee payer address
fee_payer = get_algorand_fee_payer("algorand")
print(f"Fee payer: {fee_payer}") # KIMS5H6Q...
# Validate payload structure
payload = client.extract_payload(x_payment_header)
validate_algorand_payload(payload.payload) # Raises ValueError if invalid
Building Algorand Payments (requires pip install uvd-x402-sdk[algorand])
from uvd_x402_sdk.networks.algorand import (
build_atomic_group,
build_x402_payment_request,
get_algorand_fee_payer,
)
from algosdk.v2client import algod
# Connect to Algorand node
client = algod.AlgodClient("", "https://mainnet-api.algonode.cloud")
# Build atomic group
payload = build_atomic_group(
sender_address="YOUR_ADDRESS...",
recipient_address="MERCHANT_ADDRESS...",
amount=1000000, # 1 USDC (6 decimals)
asset_id=31566704, # USDC ASA ID on mainnet
facilitator_address=get_algorand_fee_payer("algorand"),
sign_transaction=lambda txn: txn.sign(private_key),
algod_client=client,
)
# Build x402 payment request
request = build_x402_payment_request(payload, network="algorand")
Sui
Sui uses sponsored transactions with Move-based programmable transaction blocks.
from uvd_x402_sdk import X402Client, X402Config
config = X402Config(
recipient_sui="0xYourSuiAddress...",
supported_networks=["sui"],
)
client = X402Client(config=config)
result = client.process_payment(x_payment_header, Decimal("1.00"))
# Sui payload contains a user-signed PTB that the facilitator sponsors
payload = client.extract_payload(x_payment_header)
sui_data = payload.get_sui_payload()
print(f"From: {sui_data.from_address}")
print(f"To: {sui_data.to}")
print(f"Amount: {sui_data.amount}")
print(f"Coin Object ID: {sui_data.coinObjectId}")
print(f"Transaction Bytes: {sui_data.transactionBytes[:50]}...")
# Sui uses sponsored transactions (user pays ZERO SUI for gas)
# Facilitator adds sponsor signature and pays gas fees
Sui-Specific Utilities
from uvd_x402_sdk.networks.sui import (
validate_sui_payload,
is_valid_sui_address,
is_valid_sui_coin_type,
get_sui_fee_payer,
get_sui_usdc_coin_type,
get_sui_ausd_coin_type,
SUI_FEE_PAYER_MAINNET,
SUI_USDC_COIN_TYPE_MAINNET,
SUI_AUSD_COIN_TYPE_MAINNET,
)
# Validate Sui addresses (0x + 64 hex chars)
assert is_valid_sui_address("0xe7bbf2b13f7d72714760aa16e024fa1b35a978793f9893d0568a4fbf356a764a")
# Validate coin types (package::module::type format)
assert is_valid_sui_coin_type(SUI_USDC_COIN_TYPE_MAINNET)
# Get fee payer (sponsor) address
fee_payer = get_sui_fee_payer("sui") # Returns mainnet sponsor
print(f"Sui sponsor: {fee_payer}")
# Get USDC coin type
usdc_type = get_sui_usdc_coin_type("sui")
# '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
# Get AUSD coin type (mainnet only)
ausd_type = get_sui_ausd_coin_type("sui")
# '0x2053d08c1e2bd02791056171aab0fd12bd7cd7efad2ab8f6b9c8902f14df2ff2::ausd::AUSD'
# Validate Sui payment payload
payload = client.extract_payload(x_payment_header)
validate_sui_payload(payload.payload) # Raises ValueError if invalid
XRPL (XRP Ledger)
XRPL uses the t54 scheme. The user signs a Payment transaction sending native XRP to the merchant; the facilitator submits it to the ledger and pays the network fee. Payment details (payTo, amount, asset) travel in PaymentRequirements/extra, so the payload carries only the signed transaction blob.
XRPL settles in XRP, and XRP is not a dollar. A price written in USD cannot be
scaled by the network's 6 decimals — that charges 1 XRP for Decimal("1.00"). Since
0.77.0 the SDK refuses that conversion and asks you to name a dollar-pegged token the
facilitator settles on XRPL (it lists them in GET /supported; USDC is there), or to
price the call in drops yourself.
from uvd_x402_sdk import X402Client, X402Config
config = X402Config(
recipient_xrpl="r...YourXRPLAddress",
supported_networks=["xrpl"],
)
client = X402Client(config=config)
# USDC on XRPL — a dollar really is a dollar here.
XRPL_USDC = "5553444300000000000000000000000000000000.rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE"
result = client.process_payment(
x_payment_header, Decimal("1.00"),
asset=XRPL_USDC, token_decimals=6,
)
# Charging in native XRP means pricing in XRP, not in dollars:
# result = client.process_payment(header, Decimal("2.5"), asset="XRP", token_decimals=6)
# XRPL payload carries only the signed Payment transaction blob (hex)
payload = client.extract_payload(x_payment_header)
xrpl_data = payload.get_xrpl_payload()
print(f"Signed tx blob: {xrpl_data.signed_tx_blob[:50]}...")
XRPL-Specific Utilities
from uvd_x402_sdk.networks.xrpl import (
drops_to_xrp,
xrp_to_drops,
is_valid_xrpl_address,
get_xrpl_fee_payer,
)
from uvd_x402_sdk import XRPL_FEE_PAYER_MAINNET, XRPL_FEE_PAYER_TESTNET
# Native XRP uses 6 decimals (drops): 1 XRP = 1,000,000 drops
assert xrp_to_drops(1.5) == 1_500_000
assert drops_to_xrp(1_000_000) == 1.0
# Validate classic addresses (start with 'r', base58, 25-35 chars)
assert is_valid_xrpl_address("rfADKkVXBNqK3z72tVSS3LVzAR3psYkonp")
# Get the facilitator fee payer (submits the tx + pays the network fee)
fee_payer = get_xrpl_fee_payer("xrpl")
print(f"XRPL fee payer: {fee_payer}") # rfADKkVXBNqK3z72tVSS3LVzAR3psYkonp
x402 v1 vs v2
The SDK supports both x402 protocol versions with automatic detection.
Version Differences
| Aspect | v1 | v2 |
|---|---|---|
| Network ID | String ("base") |
CAIP-2 ("eip155:8453") |
| Payment delivery | JSON body | PAYMENT-REQUIRED header |
| Multiple options | Limited | accepts array |
| Discovery | Implicit | Optional extension |
Auto-Detection
from uvd_x402_sdk import PaymentPayload
# The SDK auto-detects based on network format
payload = PaymentPayload(
x402Version=1,
scheme="exact",
network="base", # v1 format
payload={"signature": "...", "authorization": {...}}
)
print(payload.is_v2()) # False
payload_v2 = PaymentPayload(
x402Version=2,
scheme="exact",
network="eip155:8453", # v2 CAIP-2 format
payload={"signature": "...", "authorization": {...}}
)
print(payload_v2.is_v2()) # True
# Both work the same way
print(payload.get_normalized_network()) # "base"
print(payload_v2.get_normalized_network()) # "base"
Creating v2 Responses
from uvd_x402_sdk import X402Config, create_402_response_v2, Payment402BuilderV2
config = X402Config(
recipient_evm="0xYourEVM...",
recipient_solana="YourSolana...",
recipient_near="your.near",
recipient_stellar="G...Stellar",
)
# Simple v2 response
response = create_402_response_v2(
amount_usd=Decimal("5.00"),
config=config,
resource="/api/premium",
description="Premium API access",
)
# Returns:
# {
# "x402Version": 2,
# "scheme": "exact",
# "resource": "/api/premium",
# "accepts": [
# {"network": "eip155:8453", "asset": "0x833...", "amount": "5000000", "payTo": "0xYour..."},
# {"network": "solana:5eykt...", "asset": "EPjF...", "amount": "5000000", "payTo": "Your..."},
# {"network": "near:mainnet", "asset": "1720...", "amount": "5000000", "payTo": "your.near"},
# ...
# ]
# }
# Builder pattern for more control
response = (
Payment402BuilderV2(config)
.amount(Decimal("10.00"))
.resource("/api/generate")
.description("AI generation credits")
.networks(["base", "solana", "near"]) # Limit to specific networks
.build()
)
Payload Validation
Each network type has specific payload validation:
EVM Validation
from uvd_x402_sdk.models import EVMPayloadContent, EVMAuthorization
# Parse and validate EVM payload
payload = client.extract_payload(x_payment_header)
evm_data = payload.get_evm_payload()
# Validate authorization fields
auth = evm_data.authorization
assert auth.from_address.startswith("0x")
assert auth.to.startswith("0x")
assert int(auth.value) > 0
assert int(auth.validBefore) > int(auth.validAfter)
SVM Validation
from uvd_x402_sdk.networks.solana import validate_svm_payload, is_valid_solana_address
# Validate SVM payload
payload = client.extract_payload(x_payment_header)
validate_svm_payload(payload.payload) # Raises ValueError if invalid
# Validate Solana addresses
assert is_valid_solana_address("YourSolanaAddress...")
NEAR Validation
from uvd_x402_sdk.networks.near import (
validate_near_payload,
is_valid_near_account_id,
BorshSerializer,
)
# Validate NEAR payload
payload = client.extract_payload(x_payment_header)
validate_near_payload(payload.payload) # Raises ValueError if invalid
# Validate NEAR account IDs
assert is_valid_near_account_id("your-account.near")
assert is_valid_near_account_id("0xultravioleta.near")
Stellar Validation
from uvd_x402_sdk.networks.stellar import (
is_valid_stellar_address,
is_valid_contract_address,
stroops_to_usd,
)
# Validate Stellar addresses
assert is_valid_stellar_address("G...YourStellarAddress") # G...
assert is_valid_contract_address("C...USDCContract") # C...
# Convert stroops to USD (7 decimals)
usd = stroops_to_usd(50000000) # Returns 5.0
Configuration
Environment Variables
# Core configuration
X402_FACILITATOR_URL=https://facilitator.ultravioletadao.xyz
X402_VERIFY_TIMEOUT=30
X402_SETTLE_TIMEOUT=55
# Recipient addresses (at least one required)
X402_RECIPIENT_EVM=0xYourEVMWallet
X402_RECIPIENT_SOLANA=YourSolanaAddress
X402_RECIPIENT_NEAR=your-account.near
X402_RECIPIENT_STELLAR=G...YourStellarAddress
X402_RECIPIENT_XRPL=r...YourXRPLAddress
# Optional
X402_FACILITATOR_SOLANA=F742C4VfFLQ9zRQyithoj5229ZgtX2WqKCSFKgH2EThq
X402_RESOURCE_URL=https://api.example.com
X402_DESCRIPTION=API access payment
Programmatic Configuration
from uvd_x402_sdk import X402Config, MultiPaymentConfig
# Full configuration
config = X402Config(
facilitator_url="https://facilitator.ultravioletadao.xyz",
# Recipients
recipient_evm="0xYourEVMWallet",
recipient_solana="YourSolanaAddress",
recipient_near="your-account.near",
recipient_stellar="G...YourStellarAddress",
# Timeouts
verify_timeout=30.0,
settle_timeout=55.0,
# Limit to specific networks
supported_networks=["base", "solana", "near", "stellar"],
# Metadata
resource_url="https://api.example.com/premium",
description="Premium API access",
# Protocol version (1, 2, or "auto")
x402_version="auto",
)
# From environment
config = X402Config.from_env()
One facilitator per network
Some deployments cannot settle every chain through the same facilitator — Coinbase's CDP facilitator, for example, does not settle Avalanche. Map the networks that need a different one; everything else is unchanged.
from uvd_x402_sdk import X402Client
client = X402Client(
recipient_address="0xYourEVMWallet",
supported_networks=["base", "avalanche"],
facilitator_by_network={
"base": "https://api.cdp.coinbase.com/platform/v2/x402",
"avalanche": "https://facilitator.ultravioletadao.xyz",
},
)
client.facilitator_url_for("base") # -> the CDP URL
client.facilitator_url_for("eip155:43114") # -> the UVD URL (CAIP-2 works too)
Three rules make this safe to run in production:
- Not configured = nothing changes. Without
facilitator_by_networkevery network resolves tofacilitator_url, exactly as before. - It never guesses. A network that is not in the table raises
ConfigurationErrorinstead of falling back tofacilitator_url— silently settling on a facilitator that does not support that chain is a money bug. Declare a fallback explicitly with the reserved"*"key:{"base": CDP, "*": UVD}. - It fails at boot, not at the first payment. An enabled network with no
route raises in the constructor. Either route every network in
supported_networks, narrow that list, or add"*". Passverify_facilitator_support=Trueto also probe each facilitator'sGET /supportedat construction and refuse a route the facilitator itself does not advertise (available on demand asclient.verify_routes()).
From the environment, as a JSON object:
X402_FACILITATOR_BY_NETWORK='{"base":"https://api.cdp.coinbase.com/platform/v2/x402","avalanche":"https://facilitator.ultravioletadao.xyz"}'
/verify, /settle (including the post-timeout re-check, which always asks the
same facilitator the settle went to) and /accepts follow the table. The
endpoints that are not network-scoped — /version, /blacklist, /api/stats,
/transactions — use facilitator_url; get_supported() and health_check()
accept an optional network= to target one facilitator.
Facilitator Addresses
The SDK includes all facilitator addresses as embedded constants. You don't need to configure them manually.
Fee Payer Addresses (Non-EVM)
Non-EVM chains require a fee payer address for gasless transactions:
from uvd_x402_sdk import (
# Algorand
ALGORAND_FEE_PAYER_MAINNET, # KIMS5H6QLCUDL65L5UBTOXDPWLMTS7N3AAC3I6B2NCONEI5QIVK7LH2C2I
ALGORAND_FEE_PAYER_TESTNET, # 5DPPDQNYUPCTXRZWRYSF3WPYU6RKAUR25F3YG4EKXQRHV5AUAI62H5GXL4
# Solana
SOLANA_FEE_PAYER_MAINNET, # F742C4VfFLQ9zRQyithoj5229ZgtX2WqKCSFKgH2EThq
SOLANA_FEE_PAYER_DEVNET, # 6xNPewUdKRbEZDReQdpyfNUdgNg8QRc8Mt263T5GZSRv
# Fogo
FOGO_FEE_PAYER_MAINNET, # F742C4VfFLQ9zRQyithoj5229ZgtX2WqKCSFKgH2EThq
FOGO_FEE_PAYER_TESTNET, # 6xNPewUdKRbEZDReQdpyfNUdgNg8QRc8Mt263T5GZSRv
# NEAR
NEAR_FEE_PAYER_MAINNET, # uvd-facilitator.near
NEAR_FEE_PAYER_TESTNET, # uvd-facilitator.testnet
# Stellar
STELLAR_FEE_PAYER_MAINNET, # GCHPGXJT2WFFRFCA5TV4G4E3PMMXLNIDUH27PKDYA4QJ2XGYZWGFZNHB
STELLAR_FEE_PAYER_TESTNET, # GBBFZMLUJEZVI32EN4XA2KPP445XIBTMTRBLYWFIL556RDTHS2OWFQ2Z
# Sui
SUI_FEE_PAYER_MAINNET, # 0xe7bbf2b13f7d72714760aa16e024fa1b35a978793f9893d0568a4fbf356a764a
SUI_FEE_PAYER_TESTNET, # 0xabbd16a2fab2a502c9cfe835195a6fc7d70bfc27cffb40b8b286b52a97006e67
# XRPL
XRPL_FEE_PAYER_MAINNET, # rfADKkVXBNqK3z72tVSS3LVzAR3psYkonp
XRPL_FEE_PAYER_TESTNET, # rGhTioKAFHe75KgVnQtacRiKFuPv28Wbwk
# Helper function
get_fee_payer, # Get fee payer for any network
)
# Get fee payer for any network
fee_payer = get_fee_payer("algorand") # Returns KIMS5H6Q...
fee_payer = get_fee_payer("solana") # Returns F742C4VfF...
fee_payer = get_fee_payer("sui") # Returns 0xe7bbf2b...
fee_payer = get_fee_payer("base") # Returns None (EVM doesn't need fee payer)
EVM Facilitator Addresses
EVM chains use EIP-3009 transferWithAuthorization (gasless by design), but the facilitator wallet addresses are available for reference:
from uvd_x402_sdk import (
EVM_FACILITATOR_MAINNET, # 0x103040545AC5031A11E8C03dd11324C7333a13C7
EVM_FACILITATOR_TESTNET, # 0x34033041a5944B8F10f8E4D8496Bfb84f1A293A8
)
Helper Functions
from uvd_x402_sdk import (
get_fee_payer, # Get fee payer address for a network
requires_fee_payer, # Check if network needs fee payer
get_all_fee_payers, # Get all registered fee payers
build_payment_info, # Build payment info with auto feePayer
DEFAULT_FACILITATOR_URL, # https://facilitator.ultravioletadao.xyz
)
# Check if network needs fee payer
requires_fee_payer("algorand") # True
requires_fee_payer("base") # False
# Build payment info with automatic fee payer
info = build_payment_info(
network="algorand",
pay_to="MERCHANT_ADDRESS...",
max_amount_required="1000000",
description="API access"
)
# info = {
# 'network': 'algorand',
# 'payTo': 'MERCHANT_ADDRESS...',
# 'maxAmountRequired': '1000000',
# 'description': 'API access',
# 'asset': '31566704',
# 'extra': {
# 'token': 'usdc',
# 'feePayer': 'KIMS5H6QLCUDL65L5UBTOXDPWLMTS7N3AAC3I6B2NCONEI5QIVK7LH2C2I'
# }
# }
Registering Custom Networks
from uvd_x402_sdk.networks import NetworkConfig, NetworkType, register_network
# Register a custom EVM network
custom_chain = NetworkConfig(
name="mychain",
display_name="My Custom Chain",
network_type=NetworkType.EVM,
chain_id=12345,
usdc_address="0xUSDCContractAddress",
usdc_decimals=6,
usdc_domain_name="USD Coin", # Check actual EIP-712 domain!
usdc_domain_version="2",
rpc_url="https://rpc.mychain.com",
enabled=True,
)
register_network(custom_chain)
# Now you can use it
config = X402Config(
recipient_evm="0xYourWallet...",
supported_networks=["base", "mychain"],
)
Multi-Token Support
The SDK supports 6 stablecoins on EVM chains. Use the token helper functions to query and work with different tokens.
Querying Token Support
from uvd_x402_sdk import (
TokenType,
get_token_config,
get_supported_tokens,
is_token_supported,
get_networks_by_token,
)
# Check which tokens a network supports
tokens = get_supported_tokens("ethereum")
print(tokens) # ['usdc', 'eurc', 'ausd', 'pyusd']
tokens = get_supported_tokens("base")
print(tokens) # ['usdc', 'eurc']
# Check if a specific token is supported
if is_token_supported("ethereum", "eurc"):
print("EURC is available on Ethereum!")
# Get token configuration
config = get_token_config("ethereum", "eurc")
if config:
print(f"EURC address: {config.address}")
print(f"Decimals: {config.decimals}")
print(f"EIP-712 name: {config.name}")
print(f"EIP-712 version: {config.version}")
# Find all networks that support a token
networks = get_networks_by_token("eurc")
for network in networks:
print(f"EURC available on: {network.display_name}")
# Output: EURC available on: Ethereum, Base, Avalanche C-Chain
Token Configuration
Each token has specific EIP-712 domain parameters required for signing:
from uvd_x402_sdk import TokenConfig, get_token_config
# TokenConfig structure
# - address: Contract address
# - decimals: Token decimals (6 for all supported stablecoins)
# - name: EIP-712 domain name (e.g., "USD Coin", "EURC", "Gho Token")
# - version: EIP-712 domain version
# Example: Get EURC config on Base
eurc = get_token_config("base", "eurc")
# TokenConfig(
# address="0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
# decimals=6,
# name="EURC",
# version="2"
# )
# Example: Get PYUSD config on Ethereum
pyusd = get_token_config("ethereum", "pyusd")
# TokenConfig(
# address="0x6c3ea9036406852006290770BEdFcAbA0e23A0e8",
# decimals=6,
# name="PayPal USD",
# version="1"
# )
Available Tokens
| Token | Description | Decimals | Issuer |
|---|---|---|---|
usdc |
USD Coin | 6 | Circle |
eurc |
Euro Coin | 6 | Circle |
ausd |
Agora USD | 6 | Agora Finance |
pyusd |
PayPal USD | 6 | PayPal/Paxos |
Critical Implementation Notes
EIP-712 Domain Names Vary by Chain
The same token may use different EIP-712 domain names on different chains. This affects signature verification.
| Token | Ethereum | Base | Avalanche |
|---|---|---|---|
| EURC | "Euro Coin" |
"EURC" |
"Euro Coin" |
| USDC | "USD Coin" |
"USD Coin" |
"USD Coin" |
| AUSD | "Agora Dollar" |
N/A | "Agora Dollar" |
| PYUSD | "PayPal USD" |
N/A | N/A |
Important: Always use get_token_config() to get the correct domain name. Never hardcode domain names.
# CORRECT: Use get_token_config for each chain
eurc_base = get_token_config("base", "eurc")
# TokenConfig(name="EURC", version="2", ...)
eurc_ethereum = get_token_config("ethereum", "eurc")
# TokenConfig(name="Euro Coin", version="2", ...)
PYUSD Signature Format (PayPal USD)
PYUSD uses the Paxos implementation which only supports the v,r,s signature variant of transferWithAuthorization. This is different from Circle's USDC/EURC which support both compact bytes and v,r,s variants.
Backend implications:
- The x402 facilitator (v1.9.0+) automatically handles this by detecting PYUSD and using
transferWithAuthorization_1(v,r,s)instead oftransferWithAuthorization_0(bytes signature) - If using a custom facilitator, ensure it supports the v,r,s variant for PYUSD
Token Info Must Be Passed to Facilitator
When using non-USDC tokens, your backend must pass the token info (including EIP-712 domain) to the facilitator. This is done via the extra field in paymentRequirements:
# When building payment requirements for the facilitator
payment_requirements = {
"asset": token_address, # Use actual token address, NOT hardcoded USDC
"extra": {
"name": token_config.name, # EIP-712 domain name
"version": token_config.version, # EIP-712 domain version
}
}
Without this, the facilitator will use wrong EIP-712 domain and signature verification will fail with "invalid signature" error.
Settle Overrides, Retry & Non-Raising Settle
Asset / EIP-712 domain overrides (non-USDC settles)
settle_payment(), verify_payment() and process_payment() accept asset and
eip712_domain overrides. By default they send the network's USDC address and the SDK
registry's domain — the overrides let YOUR token registry decide instead (required when
the token is not in the SDK registry, or when registries drift on domain names):
result = client.process_payment(
header,
Decimal("0.10"),
asset="0x01bFF41798a0BcF287b996046Ca68b395DbC1071", # the token you actually settle
eip712_domain={"name": "USD₮0", "version": "1"}, # the domain the token contract uses
)
create_authorization() accepts the same eip712_domain override — there it changes the
signed digest (and the non-USDC token.eip712 block), so the signature verifies
against the domain the verifier resolves. A partial domain (missing name or version)
raises ValueError before anything is signed or sent.
Opt-in settle retry (anti-double-settle guard)
response = client.settle_payment(payload, Decimal("0.10"), retry=True)
Default False — a single attempt, exactly as before. With retry=True: up to 3 attempts
with exponential backoff (1s, 2s), retrying only transient transport errors and 5xx.
Never retried:
- 4xx — deterministic (bad request, auth, idempotency conflict); retrying amplifies it
success=falseinside a 200 — a business error, not a transient one- A 5xx whose body already carries a transaction hash — the facilitator broadcast the tx (e.g. a non-fatal post-settle hook failed); retrying would settle TWICE
- A 5xx whose body states
"retryable": false— the facilitator saying so outright
When the facilitator refuses a retry, it says where to look
try:
client.settle_payment(payload, Decimal("0.10"), retry=True)
except FacilitatorError as exc:
if not exc.retryable:
# 502 {"error":"settlement_unconfirmed","transaction":"0x…","paymentId":"0x…"}
# The tx MAY be mined. Check the chain — never re-send.
log.error("settle unconfirmed: tx=%s payment=%s code=%s",
exc.transaction, exc.payment_id, exc.error_code)
FacilitatorError carries transaction, payment_id and error_code (all None when
the facilitator sent none), and repeats them in to_dict()["details"] as transaction /
paymentId / errorCode — so they reach the buyer through transient_503_response()
without a paywall having to know about them. The hash is passed through verbatim:
Algorand prints base32 and Solana base58, and pasting it into an explorer is the whole
remedy on offer.
exc.retryable is the verdict: the status is the ceiling and the body can only lower
it, never raise it. A body claiming retryable: true on a 400 will not make the SDK
re-send a credential the facilitator genuinely rejected.
Non-raising settle
result = client.try_settle_payment(payload, Decimal("0.10"), retry=True)
# {"success": True, "tx_hash": "0x...", "payment_id": None, "error_code": None, "error": None}
Same arguments as settle_payment(), but payment-flow errors come back as data instead of
exceptions. success=False with tx_hash set is the double-settle warning shape: the
facilitator returned an error status AFTER broadcasting — verify on-chain, do not re-send.
payment_id and error_code come back alongside it; both keys are always present (None
on the happy path) so reading them never depends on whether the settle worked.
Error Handling
from uvd_x402_sdk.exceptions import (
X402Error,
PaymentRequiredError,
PaymentVerificationError,
PaymentSettlementError,
UnsupportedNetworkError,
InvalidPayloadError,
FacilitatorError,
X402TimeoutError,
)
try:
result = client.process_payment(header, amount)
except PaymentVerificationError as e:
# Signature invalid, amount mismatch, expired, etc.
print(f"Verification failed: {e.reason}")
print(f"Errors: {e.errors}")
except PaymentSettlementError as e:
# On-chain settlement failed (insufficient balance, nonce used, etc.)
print(f"Settlement failed on {e.network}: {e.message}")
except UnsupportedNetworkError as e:
# Network not recognized or disabled
print(f"Network {e.network} not supported")
print(f"Supported: {e.supported_networks}")
except InvalidPayloadError as e:
# Malformed X-PAYMENT header
print(f"Invalid payload: {e.message}")
except FacilitatorError as e:
# Facilitator returned error
print(f"Facilitator error: {e.status_code} - {e.response_body}")
except X402TimeoutError as e:
# Request timed out
print(f"{e.operation} timed out after {e.timeout_seconds}s")
except X402Error as e:
# Catch-all for x402 errors
print(f"Payment error: {e.message}")
ERC-8004 Trustless Agents
Build verifiable on-chain reputation for AI agents and services. Supports 21 networks (19 EVM + Solana + Solana devnet).
Name Base as
"base". The old"base-mainnet"spelling is rejected by the facilitator (400 Invalid network); the SDK now rewrites it for you, but new code should use"base".
On EVM networks, agent IDs are sequential uint256 integers. On Solana, agent IDs are base58 pubkey strings (NFT asset addresses). The AgentId type (Union[int, str]) handles both.
from uvd_x402_sdk import Erc8004Client, AgentId
async with Erc8004Client() as client:
# EVM: agent_id is an integer
identity = await client.get_identity("ethereum", 42)
print(f"Agent URI: {identity.agent_uri}")
# Solana: agent_id is a base58 pubkey string
identity = await client.get_identity("solana", "8oo4dC4JvBLwy5...")
print(f"Agent URI: {identity.agent_uri}")
# Get agent reputation
reputation = await client.get_reputation("ethereum", 42)
print(f"Score: {reputation.summary.summary_value}")
# Submit feedback after payment
result = await client.submit_feedback(
network="ethereum",
agent_id=42,
value=95,
tag1="quality",
proof=settle_response.proof_of_payment,
)
# Respond to feedback (agents only)
# seal_hash is required for Solana, optional for EVM
await client.append_response(
network="ethereum",
agent_id=42,
feedback_index=1,
response_text="Thank you for your feedback!",
)
Ratings the chain attributes to the rater
submit_feedback() above works, but the registry records msg.sender as the
author -- and on that route msg.sender is the facilitator. It is why 87,2%
of the reputation on Base (1.384 of 1.587 feedbacks) is attributed to one
wallet, which can also revoke it.
EIP-7702 fixes it without touching the registry: the rater delegates their own
EOA to a FeedbackDelegate, and the transaction is sent to the rater's
address, so the registry sees the rater while the facilitator still pays the
gas.
from uvd_x402_sdk import (
Erc8004Client,
RelayAuthorizationParams,
supports_relayed_feedback,
)
async with Erc8004Client() as client:
if not supports_relayed_feedback("base"):
... # fall back to submit_feedback(); the facilitator is the author
prep = await client.prepare_relayed_feedback(
network="base",
agent_id=18896,
rater=rater_address, # who the chain will record as the author
value=95,
tag1="quality",
)
# 1. Sign with the RATER's key. WHICH value you sign depends on HOW you
# sign it -- get this wrong and you produce a well-formed signature that
# authorises nobody, and the only symptom is `relay_bad_signature`.
#
# `prep.digest` already carries the EIP-191 envelope. A raw key signs it
# as a prehash; a wallet's personal_sign would add the envelope a SECOND
# time, so wallets sign `prep.signing_payload` instead.
from eth_account import Account
signature = Account.unsafe_sign_hash(prep.digest, rater_key).signature.hex()
# ...or, from a browser/mobile wallet:
# signature = await wallet.personal_sign(prep.signing_payload)
# 2. Only the first time this rater rates: point their EOA at the delegate.
authorization = None
if not prep.delegated:
authorization = RelayAuthorizationParams(
chainId=prep.chain_id, # 0 is EIP-7702's wildcard: every chain
address=prep.delegate,
nonce=prep.account_nonce,
**sign_authorization(prep.chain_id, prep.delegate, prep.account_nonce),
)
result = await client.submit_relayed_feedback(
network="base",
agent_id=18896,
rater=rater_address,
value=95,
tag1="quality",
deadline=prep.deadline, # short by design; past it, refused
nonce=prep.nonce,
signature=signature,
authorization=authorization,
)
Pass the same feedback parameters, deadline and nonce back to
submit_relayed_feedback(). They are not redundant: the facilitator rebuilds
the registry calldata from them and refuses to relay anything the rater's
signature does not cover.
Available on the nine networks in RELAYED_FEEDBACK_NETWORKS -- the eight
mainnets with a deployed FeedbackDelegate (base, ethereum, polygon, arbitrum,
optimism, celo, bsc, monad) plus base-sepolia. Avalanche is not one of them
and is not waiting to become one: its C-Chain rejects the transaction type
itself (-32000 transaction type not supported), so anchor the rating on a
chain that supports EIP-7702 -- the payment stays where it was made.
Requires facilitator v1.93.0+ for the mainnets; base-sepolia since v1.74.0.
Server-Side Signing
Create signed EIP-3009 payment headers from your backend without a browser wallet. Useful for server-to-server x402 payments, automated agents, and testing.
pip install uvd-x402-sdk[signer]
from decimal import Decimal
from uvd_x402_sdk import X402Client
client = X402Client(recipient_address="0xMerchant...")
# Connect with a private key (reads from env or direct param)
address = client.connect_with_private_key(
private_key="YOUR_PRIVATE_KEY", # or use os.environ
chain_name="base", # EVM chain to sign for
)
print(f"Connected: {address}")
# Create a signed X-PAYMENT header
x_payment = client.create_authorization(
pay_to="0xMerchant...",
amount_usd=Decimal("1.00"),
chain_name="base",
token_type="usdc",
valid_duration=3600, # 1 hour
)
# Use the header in requests
import requests
response = requests.get(
"https://api.example.com/premium",
headers={"X-PAYMENT": x_payment},
)
Note: EVM-only. The SDK validates the chain is EVM type before signing.
/accepts Negotiation
Discover what the facilitator can settle before constructing payment authorizations. Used by Faremeter middleware and clients.
from uvd_x402_sdk import X402Client
client = X402Client(recipient_address="0xMerchant...")
# Ask facilitator what it can settle
enriched = client.negotiate_accepts([
{
"scheme": "exact",
"network": "base-mainnet",
"maxAmountRequired": "1000000",
"resource": "https://api.example.com/data",
"payTo": "0xMerchant...",
}
])
# enriched[0]["extra"] now has feePayer, tokens, escrow config
Escrow & Refunds
Hold payments in escrow with dispute resolution.
from uvd_x402_sdk import EscrowClient
async with EscrowClient() as client:
# Create escrow payment
escrow = await client.create_escrow(
payment_header=request.headers["X-PAYMENT"],
requirements=payment_requirements,
escrow_duration=86400, # 24 hours
)
# Release after service delivery
await client.release(escrow.id)
# Or request refund if service failed
await client.request_refund(
escrow_id=escrow.id,
reason="Service not delivered",
)
Bazaar Discovery
Register and discover paid x402 resources across the network.
from uvd_x402_sdk import BazaarClient
async with BazaarClient() as bazaar:
# List available resources
resources = await bazaar.list_resources(category="finance", network="base-mainnet")
for r in resources.items:
print(f"{r.url} - {r.description}")
# Only the ones a probe actually reached, best-curated first
for r in (await bazaar.list_resources(limit=50, health="alive", tier="vip")).items:
print(r.url, r.health.status, r.health.latency_ms, r.curation.label)
# Free-text search. This runs server-side over the whole catalog, so
# `pagination.total` is the real number of matches -- filtering one page
# locally is not the same thing and will under-report.
hits = await bazaar.list_resources(q="logs")
print(hits.pagination.total)
# Register your own resource
await bazaar.register_resource(
url="https://api.example.com/data",
resource_type="http",
description="Premium data API",
accepts=[{
"scheme": "exact",
"network": "eip155:8453",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "10000",
"payTo": "0xYourWallet...",
}],
metadata={"category": "finance", "tags": ["market-data"]},
)
WalletAdapter
Abstract wallet interface for signing EIP-3009 authorizations. Use EnvKeyAdapter for raw private keys or OWSWalletAdapter for Open Wallet Standard (future).
pip install uvd-x402-sdk[wallet]
from uvd_x402_sdk import EnvKeyAdapter
# Reads WALLET_PRIVATE_KEY or PRIVATE_KEY from env
wallet = EnvKeyAdapter()
print(wallet.get_address()) # 0x...
# Sign an EIP-3009 ReceiveWithAuthorization
auth = wallet.sign_eip3009({
"to": "0xRecipient...",
"amount_usdc": 0.10,
"network": "base",
})
print(auth["signature"]) # Use as X-PAYMENT header
# Sign arbitrary EIP-712 typed data
result = wallet.sign_typed_data({
"domain": {"name": "MyDapp", "version": "1", "chainId": 8453},
"types": {"Message": [{"name": "content", "type": "string"}]},
"message": {"content": "Hello"},
})
# Sign a personal message (EIP-191)
sig = wallet.sign_message("Hello, world!")
Custom WalletAdapter
Any class implementing the WalletAdapter protocol can be used:
from uvd_x402_sdk import WalletAdapter
class MyWallet:
def get_address(self) -> str: ...
def sign_message(self, message: str) -> str: ...
def sign_typed_data(self, typed_data: dict) -> "SignedTypedData": ...
def sign_eip3009(self, params: "EIP3009Params") -> "EIP3009Authorization": ...
assert isinstance(MyWallet(), WalletAdapter) # True (runtime_checkable)
ERC-8128 Signed HTTP Requests (sign_request / fetch_nonce)
Sign HTTP requests per ERC-8128 (Signed HTTP
Requests with Ethereum, RFC 9421) with any WalletAdapter. This is how agents
authenticate against wallet-signed APIs — most notably Execution Market, where
API keys are rejected in production and only wallet signing is accepted. The
private key never leaves the adapter; only get_address() and
sign_message() (EIP-191 personal_sign) are used.
from uvd_x402_sdk import EnvKeyAdapter, fetch_nonce, sign_request
wallet = EnvKeyAdapter() # reads WALLET_PRIVATE_KEY from env
# 1. Fresh single-use nonce (5-minute TTL) — one per signed request,
# including retries: the server consumes it before verification.
nonce = await fetch_nonce("https://api.execution.market")
# 2. Sign the request. Body must be byte-identical to what goes on the wire.
headers = sign_request(
wallet,
method="POST",
url="https://api.execution.market/api/v1/tasks",
body='{"title": "test"}',
nonce=nonce,
)
# {"Signature": "eth=:...:", "Signature-Input": "eth=(...)", "Content-Digest": "sha-256=:...:"}
# 3. Merge into the request headers and send.
The wire format is pinned and byte-tested against golden vectors
(tests/fixtures/erc8128.json): alg="eip191" always emitted, keyid always
lowercase (erc8128:{chain_id}:{address}), signature params in the order
created;expires;nonce;keyid;alg. Covered components: @method,
@authority, @path, plus @query when the URL has a query string and
content-digest (SHA-256, RFC 9530) when the request has a body.
Escrow Pre-Auth Builder (build_escrow_pre_auth / compute_escrow_nonce)
Sign the escrow lock authorization for sign-on-assignment marketplaces
(Execution Market ADR-002) with any WalletAdapter. The EIP-3009 nonce is
AuthCaptureEscrow.getHash(paymentInfo) which includes the receiver — the
signature commits to the chosen worker, so it can only be created AT
ASSIGNMENT. Returns the raw JSON X-Payment-Auth header value the backend
relays verbatim to the Facilitator POST /settle.
import httpx
from uvd_x402_sdk import EnvKeyAdapter, build_escrow_pre_auth
# Full response of GET /api/v1/h2a/payment-config (never hardcode the domain)
config = httpx.get(
"https://api.execution.market/api/v1/h2a/payment-config"
).json()
payment_auth = build_escrow_pre_auth(
payment_config=config,
network="base",
payer="0xPublisher...", # must match the signing wallet
receiver="0xWorker...", # committed by the nonce
amount_usd=0.10,
deadline=task_deadline_epoch, # release window outlasts it
wallet=EnvKeyAdapter(),
)
# Send as the X-Payment-Auth header on the assignment request.
Fail-loud: an unknown network or incomplete network config raises
ValueError — a silent domain fallback would sign a mismatched,
wallet-draining authorization. On-chain limits enforced client-side: bounty
<= $100 (AuthCaptureEscrow deposit condition) and the signed maxFeeBps must
cover the operator's 1300 bps static fee. compute_escrow_nonce() is the
standalone, dict-based equivalent of AdvancedEscrowClient._compute_nonce
(no web3 needed — only eth-abi/eth-utils, pulled in by eth-account).
The wrapper is byte-tested against golden vectors
(tests/fixtures/escrow-preauth.json, shared with the Execution Market web,
mobile and plugin-SDK suites).
Signed escrow lifecycle orders (build_lifecycle_auth)
release and refundInEscrow move money that is already escrowed, so
neither carries an ERC-3009 signature — there is no transfer left to authorize.
That left the other half of the question open: who is entitled to ask for the
move. Until now, whoever called. On 2026-08-30 a third party probed exactly
that: five calls with a fabricated paymentInfo, two of them mined.
The facilitator (x402-rs, PR #21) now verifies an EIP-712 order signed by
the party the action belongs to. This is the other end of that cable — the part
that signs it.
| action | accepted signers |
|---|---|
release |
the payer; the operator owner (FEE_RECIPIENT(), read on-chain) |
refundInEscrow |
the receiver; the operator owner; the payer, but only once authorizationExpiry has passed |
The receiver may never release (paying yourself out of an escrow is what
escrow exists to stop) and the payer may never refundInEscrow before expiry
(that is the chargeback).
from uvd_x402_sdk import AdvancedEscrowClient, EnvKeyAdapter
client = AdvancedEscrowClient(...)
# The signer is INJECTED — the SDK never reads a key from the environment on
# its own. Any WalletAdapter works (EnvKeyAdapter, a KMS, a browser wallet).
tx = client.release_via_facilitator(
payment_info,
lifecycle_signer=EnvKeyAdapter(private_key), # must be the payer or the operator owner
)
Or build the block yourself and attach it to any request:
from uvd_x402_sdk import build_lifecycle_auth
auth = build_lifecycle_auth(
action="release", # or "refundInEscrow"
payment_info=payment_info, # the wire dict, camelCase, salt as hex
payer=payer_address, # payload.payer, NOT inside payment_info
amount=1_000_000, # the SAME amount as payload.amount
chain_id=8453,
wallet=adapter,
)
# -> {"signer", "deadline", "nonce", "signature"}, goes at payload.lifecycleAuth
Rollout is by facilitator mode (ESCROW_LIFECYCLE_AUTH): off (the order
is not looked at), log (verify when present, log the verdict, never reject)
and enforce. lifecycle_signer is optional at every layer, so a caller that
passes nothing sends the byte-identical request it sent before.
Things worth knowing, each of which is a rejection the facilitator names and the caller cannot see:
paymentInfo.saltisbytes32on the wire anduint256in the signature. The facilitator converts it (U256::from_be_bytes); signing it as a string produces a different digest and a silentbad_signature. The SDK converts it for you.amountis signed. Signingmax_amountand submitting a partial release is an order that does not verify — and a partial is the normal case for a stream.deadlinehas a 900 s ceiling (deadline_too_far). The default signsnow + 600, which is what keeps a facilitator clock a few seconds behind yours from deciding the verdict.nonceis consumed on acceptance (replayed). It defaults to a fresh random 32 bytes; a stream emitting one order per delta needs one each.- The order is signed over the paymentInfo that is submitted, so neither
side recomputes
getHashand there is nothing to drift — and an intermediary cannot change the receiver after the fact.
build_lifecycle_typed_data() exposes the EIP-712 document itself, which is
the seam the TypeScript twin mirrors — and the seam a browser signs through.
When someone else signs (lifecycle_auth)
The party entitled to the move is often not the process asking for it. The
payer signs release in their browser; a backend transports it. That backend
does not have — and must not have — the payer's key, so lifecycle_signer is
the wrong seam: it signs with a key in the process.
lifecycle_auth is the other one. It takes an order already signed and
attaches it verbatim:
from uvd_x402_sdk import (
build_lifecycle_typed_data,
lifecycle_auth_from_signature,
)
# 1. The backend builds the document. Nothing secret here — ship it to the
# browser as JSON and let the wallet sign it (eth_signTypedData_v4).
typed = build_lifecycle_typed_data(
action="release",
payment_info=payment_info, # the wire dict, camelCase, salt as hex
payer=payer_address,
amount=1_000_000,
chain_id=8453,
deadline=int(time.time()) + 600, # 900 s ceiling; leave yourself slack
nonce="0x" + secrets.token_hex(32),
)
# 2. The browser returns 65 bytes and nothing else.
auth = lifecycle_auth_from_signature(
typed_data=typed,
signature=signature_from_browser,
signer=payer_address,
)
# 3. Transported as-is. Not re-signed, and the nonce and deadline are the
# ones that entered the digest.
tx = client.release_via_facilitator(payment_info, lifecycle_auth=auth)
deadlineandnonceare not passed separately. They are read out of thetyped_datathat was signed. Declaring them alongside would let the wire announce one window while the signature covers another — abad_signatureneither side can name.- The signature is verified against
signerbefore it is returned. The real failure of the browser path is that the page signs with whichever account is connected while the backend believes it was another one. The facilitator answersbad_signatureto that and does not say which of the two was wrong;lifecycle_auth_from_signaturedoes. (It needseth-accountfor the recovery —pip install uvd-x402-sdk[signer].) lifecycle_signerandlifecycle_authare mutually exclusive. One signs a new order with its own nonce and deadline, the other carries a foreign one; picking silently would submit an order the caller did not intend, and onreleasethat is money moving. Passing both raises.- Both paths produce the same wire block, pinned by a test: the browser route is not a second dialect.
x402 v2 requests (build_verify_request_v2 / build_settle_request_v2)
If the 402 you received advertises CAIP-2 networks (eip155:8453), you are
speaking v2 and must send the v2 envelope.
Since v0.74.0 the client does this for you. X402Client.verify_payment and
settle_payment pick the envelope from the wire: a CAIP-2 network gets v2, a
plain name stays on v1, and X402Config(x402_version=1|2) pins it either way.
Nothing to call, nothing to pass — see
Choosing the envelope below.
Reach for the builders directly when you are assembling a body outside the
client — echoing a vendor's accept verbatim, or driving the facilitator
without a PaymentPayload in hand:
from uvd_x402_sdk import (
AcceptedRequirementsV2, ResourceInfoV2, build_verify_request_v2,
)
body = build_verify_request_v2(
payload={"signature": "0x...", "authorization": {...}},
resource=ResourceInfoV2(
url="https://api.example.com/thing",
description="Thing",
mime_type="application/json",
),
accepted=AcceptedRequirementsV2(
scheme="exact",
network="eip155:8453",
asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount="100000",
pay_to="0xabc...",
max_timeout_seconds=300,
),
)
httpx.post(f"{facilitator_url}/verify", json=body)
Plain dicts work too, which matters because the spec makes you echo the vendor's accept verbatim — you will usually have a dict, not a model.
| v1 envelope | v2 envelope | |
|---|---|---|
| top level | {x402Version, paymentPayload, paymentRequirements} |
{x402Version, paymentPayload, resource, accepted} |
| network | plain name — base |
CAIP-2 — eip155:8453 |
| amount field | maxAmountRequired |
amount |
resource |
URL string | object {url, description, mimeType} |
Do not mix levels. Each version demands its own network format and its own envelope. A v2 payload inside a v1 envelope — or a plain network name inside a v2 request — matches no variant at the facilitator and fails with
data did not match any variant of untagged enum VerifyRequestEnvelope, an error that names no field. If you see it, check the envelope shape first, not the fields inside it.
Choosing the envelope (x402_version)
X402Config.x402_version decides which envelope verify_payment() and
settle_payment() send. It defaults to "auto":
# auto (default): CAIP-2 on the wire -> v2, plain name -> v1
client = X402Client(recipient_address="0x...")
client.verify_payment(payload, Decimal("0.01")) # payload.network == "base"
# -> v1 envelope
client.verify_payment(payload_caip2, Decimal("0.01")) # "eip155:8453"
# -> v2 envelope
# pin, when you know better than the wire
client = X402Client(config=X402Config(recipient_evm="0x...", x402_version=1))
Auto reads the network, not the version marker, and that is measured against
production rather than assumed. The facilitator's envelope enum is untagged —
it matches on shape and ignores x402Version — so a header that merely declares
version 2 while carrying plain network names is served correctly today and is a
400 in the v2 envelope. Upgrading it on the strength of the marker would
break a call that works.
Both envelopes answer 200 for a CAIP-2 pair against facilitator 2.10.0 and
reduce to the same payment, but only v2-with-CAIP-2 is accepted by builds older
than 2026-09-04, where v1-with-CAIP-2 is a hard 400 (unknown variant eip155:8453``). Choosing v2 there is what makes one client work against both.
XRPL has no CAIP-2 form — its v1 string is its identifier — so it stays on v1
under auto. An explicit x402_version=2 on it raises rather than sending a v1
network name inside a v2 body.
resolve_envelope_version() (and therefore the "auto" default) reads a v2
payload as well as a flat v1 one. A v2 payload has no top-level network at
all — v2 moved the chain id into accepted — so auto reads it from there, and
a payload carrying neither resolves to v1 instead of raising. Before 0.75.0
it read only the top level and blew up on exactly that shape
(AttributeError: 'dict' object has no attribute 'network', or
TypeError: argument of type 'NoneType' is not iterable), which is why
integrators were pinning x402_version instead of using the default.
from uvd_x402_sdk.envelope import resolve_envelope_version
v2_payload = { # what a buyer following a v2 402 sends
"x402Version": 2,
"resource": {"url": ..., "description": ..., "mimeType": ...},
"accepted": {"scheme": "exact", "network": "eip155:8453", ...},
"payload": {"signature": "0x...", "authorization": {...}},
}
resolve_envelope_version(v2_payload, requirements) # -> 2
The same choice is exposed as functions for callers driving the facilitator
themselves: resolve_envelope_version(), build_verify_request_for_version(),
build_settle_request_for_version(), and the conversion
to_resource_info_v2() / to_accepted_requirements_v2().
Metrics and history (get_stats / get_transactions)
stats = client.get_stats()
for row in stats["byNetworkAndAsset"]:
# Use the row's OWN decimals. USDC is 6 nearly everywhere and 18 on BSC —
# scaling by a constant 6 overstates BSC volume by 10^12.
print(row["network"], row["settlesOk"], row["volumeAtomic"], row["decimals"])
recent = client.get_transactions(limit=20, network="base")
An index, not a ledger. Records are written best-effort after settlement, so an outage loses rows while payments proceed — verify anything that matters against the transaction hash. Counting starts when the operator enabled the store, so earlier operations are unknown, not zero. And unless failure publishing is on, operations that error are not recorded at all, so a 100% success rate means "no failures were recorded".
get_transactionshas no pagination: it returns the newest N (capped at 200), walking back at most 30 days.
Live Traffic Stream (GET /events)
The facilitator emits one Server-Sent Event per operation it handles, so you can render or react to live traffic without polling.
from uvd_x402_sdk import TrafficEventStream
with TrafficEventStream() as stream:
for event in stream:
print(event.kind, event.network, event.ok, event.tx)
# Only settlements on the chains you care about. The facilitator has NO
# server-side filter by network, so this runs client-side.
with TrafficEventStream(networks=["base", "polygon"], kinds=["settle"]) as stream:
for event in stream:
print(event.tx, event.timestamp)
# Async, for an event loop
async with TrafficEventStream() as stream:
async for event in stream:
print(event.network)
Three properties decide how you should use this:
It is lossy by design. The facilitator will never slow down or fail a payment to keep an observer in sync, so an event you were not connected for is gone. Treat it as a live hint and use the chain as the source of truth — and note that absence of events is not evidence that nothing happened. On a quiet rail the only thing on the wire for minutes is a keepalive.
Failed operations are not published. Only operations that resolved emit an
event, so ok=False means "resolved and came back negative", never "blew up". A
stream that looks healthy is not proof that the rail is.
Admission is bounded. /events is public and unauthenticated, so it sheds
with HTTP 503 + Retry-After at subscriber capacity, and returns 404 when the
operator disabled it. Both raise FacilitatorError with status_code intact.
Match the canonical network slug.
networkis the name/supporteduses, which is not always the alias you may send.skaleis accepted inbound, but events always sayskale-base. Keying on the alias silently drops every event for that chain.
| Field | Notes |
|---|---|
ts |
Unix epoch milliseconds (not seconds); event.timestamp gives a UTC datetime |
kind |
"verify" or "settle" |
network |
Canonical slug, same as /supported |
ok |
Resolved successfully? |
payer / amount / asset |
Omitted in minimal detail mode |
tx |
Present on settle, absent on verify — nothing settled yet |
Facilitator Info
Query the facilitator for version, supported networks, blacklist, and health.
from uvd_x402_sdk import X402Client
client = X402Client(recipient_address="0xYourWallet...")
# Check version
version = client.get_version()
print(f"Facilitator: v{version['version']}")
# List supported networks
supported = client.get_supported()
for kind in supported["kinds"]:
print(f" {kind['network']} - {kind['scheme']}")
# Check blacklist
bl = client.get_blacklist()
print(f"Blocked addresses: {bl['totalBlocked']}")
# Health check
is_healthy = client.health_check()
Escrow State Queries
Query on-chain escrow state without performing settlement.
from uvd_x402_sdk import EscrowClient
async with EscrowClient() as escrow:
state = await escrow.get_escrow_state(
network="base-mainnet",
payer="0xPayer...",
recipient="0xRecipient...",
nonce="0x1234...",
)
print(f"Status: {state['status']}, Balance: {state.get('balance')}")
How x402 Works
The x402 protocol enables gasless stablecoin payments (USDC, EURC, AUSD, PYUSD):
1. User Request --> Client sends request without payment
2. 402 Response <-- Server returns payment requirements
3. User Signs --> Wallet signs authorization (NO GAS!)
4. Frontend Sends --> X-PAYMENT header with signed payload
5. SDK Verifies --> Validates signature with facilitator
6. SDK Settles --> Facilitator executes on-chain transfer
7. Success <-- Payment confirmed, request processed
The facilitator (https://facilitator.ultravioletadao.xyz) handles all on-chain interactions and pays gas fees on behalf of users.
Payment Flow by Network Type
| Network Type | User Signs | Facilitator Does |
|---|---|---|
| EVM | EIP-712 message | Calls transferWithAuthorization() |
| SVM | Partial transaction | Co-signs + submits transaction |
| NEAR | DelegateAction (Borsh) | Wraps in Action::Delegate |
| Stellar | Auth entry (XDR) | Wraps in fee-bump transaction |
| Algorand | ASA transfer tx | Signs fee tx + submits atomic group |
| Sui | Programmable tx block | Sponsors gas + submits transaction |
| XRPL | Payment tx (signed blob) | Submits tx + pays network fee |
Error Codes
| Exception | Description |
|---|---|
PaymentRequiredError |
No payment header provided |
PaymentVerificationError |
Signature invalid, amount mismatch, expired |
PaymentSettlementError |
On-chain settlement failed |
UnsupportedNetworkError |
Network not recognized or disabled |
InvalidPayloadError |
Malformed X-PAYMENT header |
FacilitatorError |
Facilitator service error |
ConfigurationError |
Invalid SDK configuration |
X402TimeoutError |
Request timed out |
Security
- Users NEVER pay gas or submit transactions directly
- EVM: Users sign EIP-712 structured messages for any supported stablecoin (USDC, EURC, AUSD, PYUSD)
- Solana/Fogo: Users sign partial transactions (facilitator co-signs and submits)
- Stellar: Users sign Soroban authorization entries only
- NEAR: Users sign NEP-366 meta-transactions (DelegateAction)
- Sui: Users sign programmable transaction blocks (facilitator sponsors gas)
- XRPL: Users sign a native-XRP Payment transaction (facilitator submits it and pays the ledger fee)
- The facilitator submits and pays for all on-chain transactions
- All signatures include expiration timestamps (
validBefore) for replay protection - Nonces prevent double-spending of authorizations
- Each token has verified contract addresses and EIP-712 domain parameters
Troubleshooting
Common Issues
"Unsupported network"
- Check that the network is in
supported_networks - Verify the network is enabled
- For v2, ensure CAIP-2 format is correct
"Payment verification failed"
- Amount mismatch between expected and signed
- Recipient address mismatch
- Authorization expired (
validBeforein the past) - Nonce already used (replay attack protection)
"Settlement timed out"
- Network congestion - increase
settle_timeout - Facilitator under load - retry after delay
"Invalid payload"
- Check base64 encoding of X-PAYMENT header
- Verify JSON structure matches expected format
- Ensure
x402Versionis 1 or 2
Debug Logging
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("uvd_x402_sdk").setLevel(logging.DEBUG)
Development
# Clone and install
git clone https://github.com/UltravioletaDAO/uvd-x402-sdk-python
cd uvd-x402-sdk-python
pip install -e ".[dev]"
# Run tests
pytest
# Format code
black src tests
ruff check src tests
# Type checking
mypy src
Links
License
MIT License - see LICENSE file.
Changelog
v0.79.0 (2026-09-06)
- Added:
release_via_facilitator()/refund_via_facilitator()acceptlifecycle_auth=— a lifecycle order already signed by someone else, attached topayload.lifecycleAuthverbatim. 0.78.0 shipped onlylifecycle_signer=, which signs with a key held in the calling process; that is the wrong shape for the flow the owner picked, where the payer signs and Execution Market transports. EM's own handoff measured the gap: "Mientras no acepte unlifecycle_auth=, no puede transportar la orden de un tercero" — their side is already merged behindEM_LIFECYCLE_PAYER_SIGNS(off), waiting on this - Added:
lifecycle_auth_from_signature(typed_data, signature, signer)— the browser path's other half.build_lifecycle_typed_data()(public since 0.78.0) produces the EIP-712 document, a wallet returns 65 bytes, and this pairs them into the wire block.deadlineandnonceare read out of the signed typed data, never passed alongside: both enter the digest, so declaring them separately would let the wire announce one window while the signature covers another - The signature is verified against
signerbefore anything is returned. The real failure of a browser flow is the page signing with whichever account is connected while the backend believes it was another; the facilitator answersbad_signatureand does not say which of the two was wrong. Recovering locally is the only place that can. A wrong-length signature and a typed document from another domain are refused by name too lifecycle_signerandlifecycle_authare mutually exclusive, and passing both raises before the POST. One signs a new order with its own nonce and deadline, the other carries a foreign one — choosing silently would submit an order the caller did not intend, and onreleasethat is money moving- Nothing is normalized on the way out. The order travels byte-for-byte: trimming a hex, reordering keys or recomputing the window would send something other than what entered the digest. Pinned by a test that compares the captured request against the block handed in
- Both paths produce the same wire block.
lifecycle_auth_from_signatureon a signature over the same typed data equalsbuild_lifecycle_authfor the same nonce and deadline — asserted, so the browser route cannot drift into a second dialect that the pinned vectors do not cover - Backward compatible:
lifecycle_authdefaults toNoneand, with neither argument, the request is byte-identical to 0.78.0 and to everything before it. 925 tests pass (916 before, 9 added, none lost)
v0.78.0 (2026-09-05)
- Added: you can now sign
releaseandrefundInEscrow.build_lifecycle_auth()produces the EIP-712 order the facilitator verifies, andrelease_via_facilitator()/refund_via_facilitator()take alifecycle_signerthat attaches it. Until now nothing in either SDK could produce one: measured in the facilitator's own logs, 2,953 release/refund calls over 17 days from 22 payers and 38 receivers across 9 networks, zero of them signed — the field did not exist and no caller sent it - Why it matters: both actions move money that is already escrowed, so neither carries an ERC-3009 signature — there is no transfer left to authorize. That left who is entitled to ask for the move answered by "whoever called". On 2026-08-30 a third party probed exactly that: five calls with a fabricated
paymentInfo, two of them mined, gas spent - The format is the facilitator's, verbatim (
x402-rs,src/payment_operator/lifecycle_auth.rs, PR #21): aLifecycleOrder(string action, uint256 amount, uint256 deadline, bytes32 nonce, PaymentInfo paymentInfo)under the domain{"x402 escrow lifecycle", "1", chainId}— noverifyingContract, becausepaymentInfo.operatoralready travels inside the signed struct.PaymentInfois theAuthCaptureEscrowtype string this SDK already types for ERC-3009, so a client that has it reuses it instead of learning a second struct - Who may sign what:
release— the payer, or the operator owner (FEE_RECIPIENT(), read on-chain).refundInEscrow— the receiver, the operator owner, or the payer onceauthorizationExpiryhas passed. The receiver may never release (paying yourself out of an escrow is what escrow exists to stop) and the payer may never refund before expiry (that is the chargeback) - Backward compatible, by design.
lifecycle_signeris optional at every layer and defaults toNone; without it the request goes out byte-identical to before, which is what the facilitator'soffandlogmodes still accept. The rollout is the facilitator'sESCROW_LIFECYCLE_AUTH:off→log(verify when present, log the verdict, never reject) →enforce - The signer is injected, never read. The SDK does not go looking for a key in the environment — pass any
WalletAdapter(EnvKeyAdapter, a KMS, a browser wallet) - Three rejections are caught here rather than there, because in
enforceeach one is stuck money and the caller has no access to the facilitator's log: adeadlinein the past (expired), one past the 900 s ceiling (deadline_too_far— the default signsnow + 600, so a facilitator clock a few seconds behind yours does not decide the verdict), and apaymentInfomissing a field, which is refused by name instead of being defaulted into a signature over a struct that is not the one submitted paymentInfo.saltisbytes32on the wire anduint256in the signature (the facilitator converts it withU256::from_be_bytes). Signing it as a hex string produces a different digest and abad_signaturewhose only symptom is that no order ever verifies. The SDK converts it- The amount signed is the amount submitted. Signing
max_amountwhile submitting a partial release is an order that does not verify — and a partial is the normal case for a stream, which emits one order, and one nonce, per delta - Verified against the live facilitator in
logmode (GET /settle→"escrowLifecycleAuth":"log"), on base-sepolia, with no funds and no transaction: a payer-signed release loggedverdict="ok"(2026-09-06T01:27:49.199084Z) — the first one ever — and a stranger-signed one loggedverdict="unauthorized_role", "is neither a party to this escrow nor the operator owner" (01:29:45.099379Z) - Cross-language conformance unaffected: 266 checks across 5 phases, passing against the TypeScript SDK. The TypeScript twin does not yet emit
lifecycleAuth; the exact wire block is written down for it indocs/handoffs/2026-09-05-lifecycle-auth-firma.md
v0.77.0 (2026-09-05)
- Fixed: XRPL charged in XRP what the integrator wrote in dollars.
process_payment(header, Decimal("1.00"))on XRPL producedmaxAmountRequired = 1000000drops — 1 XRP, not one dollar. The scaling was right (XRP really has 6 decimals); the unit was wrong.NetworkConfig.get_token_amount()multiplies a USD price by10**decimals, which only turns dollars into base units when one whole unit IS a dollar — true for every USDC/EURC/AUSD/PYUSD/USDT/USDG network in the registry, false for a chain that settles in its own floating native asset. Any endpoint priced in USD over XRPL has been charging the XRP price of its number, in whichever direction the market moved - Added:
NetworkConfig.usd_pegged(defaultTrue, so the 23 other networks are byte-for-byte unchanged). XRPL mainnet and testnet carryFalse, and bothget_token_amount()and the client's requirements builder now refuse rather than convert. The error names the asset, shows what the old code would have charged, and points atGET /supportedfor a pegged token — a refusal that does not say where to look just moves the dead end token_decimalsdoes not open this door. It fixes SCALE and the defect is UNIT: six decimals of XRP are still XRP. The way through is naming anasset— the facilitator settles a dollar-pegged USDC on XRPL (issuerrGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE, Circle) — with itstoken_decimals, or pricing the call in drops yourself- Fixed: XRPL mainnet is
xrpl, notxrpl-mainnet. The facilitator advertisesxrplinGET /supportedand printsxrpleverywhere (x402-rs/src/network.rs:189); it takesxrpl-mainnetonly as an informalFromStralias, which its own source calls "right for a lookup and wrong for a wire format" (network.rs:719). The SDK put the alias on the wire, soX402Client(..., verify_facilitator_support=True)refused to start with "does not settle: xrpl-mainnet" against a network the facilitator settles fine. Measured before and after against the live facilitator; the testnet name already matched and does not move xrpl-mainnetkeeps resolving as an alias (get_network,normalize_network,X402Config.supported_networks), through the same_NETWORK_ALIASEStable that already carriedskale. It is no longer a separate registry entry, so network counts and listings are unchanged, and it is no longer what the SDK emits —validate_network("xrpl-mainnet")now returns"xrpl"- Both defects were surfaced by the audit of PR #2 (Casper), as pre-existing defects of
mainthat the PR would have inherited — seedocs/reports/2026-09-05-auditoria-pr2-casper.md(H2, and the closing recommendation) - 889 tests pass (881 before, 8 added, none lost); cross-language conformance against the TypeScript SDK still 266 checks PASSED;
ruffandmypycounts unchanged (63 / 120)
v0.76.0 (2026-09-04)
- Fixed: the SDK refused to retry an unconfirmed settlement and then threw away the one thing that made the refusal actionable. The facilitator answers
502 {"error":"settlement_unconfirmed","transaction":"0x…","paymentId":"0x…","retryable":false}— the tx MAY be mined, so retrying is paying twice — and the anti-double-settle guard already stopped the loop. But_extract_tx_hash_from_bodywas private: the hash fed the verdict, went into a warning log, and was discarded. The caller got "not retryable" and nothing to check, which rebuilds the same dead end one layer up: whoever paid cannot find out whether their money moved - Added:
FacilitatorError.transaction,.payment_idand.error_code, repeated into_dict()["details"]astransaction/paymentId/errorCode— so they reach the buyer throughtransient_503_response()without a paywall having to know about them.try_settle_payment()returns them as data (payment_id,error_code; both keys always present,Noneon the happy path). The hash travels verbatim — Algorand prints base32, Solana base58, and reformatting it makes it unpastable into an explorer - Fixed, the second decision site:
FacilitatorError.__init__computedretryablepurely from the status (None | 429 | >=500) without reading the body — the same hole TypeScript found inErc8004LookupError. The status is now the CEILING and the body can only LOWER it, never raise it: a body claimingretryable: trueon a400will not make the SDK re-send a credential the facilitator genuinely rejected. Three signals lower it, in order of authority: an explicitretryable: false(a contract the facilitator states, until now ignored outright); any 5xx carrying a hash whatever the error is called (the general form — it covers codes that do not exist yet); nothing else - Because the verdict now lives in the constructor, every path that raises one — settle, verify, escrow, events, ERC-8004 — reaches it by construction instead of each re-deriving it. One parser (
parse_facilitator_error_body) serves both decision sites: two subtly different readings of the same body is how one code path stops honouring theretryable: falsethat the other honours - Unchanged: the transient
502— the one withRetry-Afterand no hash — still retries with the same 3 attempts and the same clamped wait. The settle loop still never re-POSTs a429. A4xxstill carries noretryablekey in itsto_dict().anti_double_settle=Falsestill disarms the hash INFERENCE — but not the explicit contract, which is not the caller's to contradict - Symmetric with the TypeScript SDK 2.80.0 (
FacilitatorFailureFields), same three fields and the same ceiling rule
v0.75.0 (2026-09-04)
- Fixed:
"auto"— the default — crashed on the one payload shape it exists to route. A v2 payload has no top-levelnetworkat all (v2 moved the chain id intoaccepted), andresolve_envelope_version()read onlypayload.networkand handed it straight tois_caip2_format(":" in network). Measured in runtime against the published 0.74.0:AttributeError: 'dict' object has no attribute 'network'on a v2 envelope,TypeError: argument of type 'NoneType' is not iterablewhen the network isNone - The consequence was already in production: MeshRelay's turnstile and multibrain pin
x402_versionto 1 or 2 explicitly rather than use the default, so this SDK's own default was the one option no consumer could use. Those pins can come out once this version is published - The rule is now written down (
_network_of_payload): the network is read wherever the payload keeps it — top level if present, otherwiseaccepted.network— and the top level wins when it is there, so a v1 payload keeps reading its own. A payload carrying neither contributes no CAIP-2 evidence and stays on v1 instead of raising build_verify_request_for_version()/build_settle_request_for_version()take the same shapes: resolving to 2 and then raising one line later on the same object was half a fix. A payload with no signedpayloadblock now refuses by name instead of producing the facilitator's "matched no variant", which names no field- The v2 payload travels unreshaped through the v1 envelope too — this module chooses the envelope, it does not translate one payload shape into the other (
X402Client.extract_payloadis what flattens a v2 header) - Purely a fix: every wire that resolved before resolves to the same version, and the v1 body from a
PaymentPayloadis byte-for-byte unchanged. Same defect the TypeScript SDK fixed in 2.79.0, with the same rule, so the same wire produces the same body in both SDKs
v0.74.0 (2026-09-04)
- Fixed:
verify_payment()andsettle_payment()wrote"x402Version": 1as a literal, so the SDK could advertise x402 v2 in a 402 and was then structurally unable to speak it — a payer that believed our own 402 got a 400 back. The v2 builders (build_verify_request_v2/build_settle_request_v2) had existed since v0.62.0 with no caller;X402Config.x402_versionwas declared, documented as "1, 2 or auto", and read by nothing. Same defect the TypeScript SDK fixed in 2.78.0, after it broke a real ChatGPT payment - Added:
uvd_x402_sdk.envelope—resolve_envelope_version(),build_verify_request_for_version(),build_settle_request_for_version(), and the v1 → v2 conversion (to_resource_info_v2(),to_accepted_requirements_v2()). Consumers write no new code: keep passing the samePaymentPayload, the client picks the envelope X402Config.x402_versionnow does what it says."auto"(the default) upgrades to v2 when the network on the wire is CAIP-2 (eip155:8453) and stays on v1 for plain names — including a header that merely declares version 2, which the facilitator's untagged envelope enum serves correctly today and which is a 400 in v2.1or2pins, and a pin wins over the wire- Not purely additive, and this is the one behaviour change: a CAIP-2 wire now travels in the v2 envelope. Both envelopes answer 200 against facilitator 2.10.0 and reduce to the same payment, but only v2-with-CAIP-2 is accepted by facilitator builds older than 2026-09-04, where v1-with-CAIP-2 is a hard 400. Set
x402_version=1to restore the previous body byte-for-byte. The v1 path itself is untouched - Verified end-to-end against the live
/verifywith the SDK building the body, and pinned against the TypeScript SDK: the two now emit byte-identical/verifyand/settlebodies for the same wire - A network with no CAIP-2 form (XRPL — its v1 string is its identifier) stays on v1 under
auto, and raises under an explicitx402_version=2rather than silently sending a v1 network name inside a v2 body
v0.44.0 (2026-08-11)
- Added: per-network facilitator routing —
X402Config(facilitator_by_network={"base": CDP_URL, "avalanche": UVD_URL})(alsoX402Client(...)andconfigure_x402(...), and theX402_FACILITATOR_BY_NETWORKenv var as a JSON object).verify,settle, the post-timeout settle re-check and/acceptseach go to the facilitator that owns their network - The translation refuses to guess: a network that is neither in the table nor covered by the reserved
"*"fallback key raisesConfigurationError— it is NEVER routed tofacilitator_urlsilently. Settling on a facilitator that does not settle that chain is a money bug, not a config nit - Boot fails early: an ENABLED network left unrouted raises in the
X402Configconstructor, not on the first payment.X402Client(..., verify_facilitator_support=True)additionally probes each facilitator'sGET /supportedat construction and raises if one does not advertise a network routed to it (also available on demand asclient.verify_routes()) - Added
X402Config.facilitator_url_for(network),X402Config.facilitator_routes(),X402Client.facilitator_url_for()/verify_routes(), and optionalnetwork=onget_supported()/health_check()to target a single facilitator - Purely additive — with no
facilitator_by_networkthe resolution returnsfacilitator_urlfor every input, including unknown networks, and the wire is byte-identical.FACILITATOR_URLS(which maps ENVIRONMENT, not network) is untouched - Reported by NomiCheck, who route
basethrough Coinbase's CDP facilitator andavalanchethrough Ultravioleta's — CDP does not settle avalanche — and had to maintain a routing layer of their own on top of the SDK because a singlefacilitator_urlcould not express it
v0.36.0 (2026-08-02)
- Added:
assetandeip712_domainoverrides onsettle_payment(),verify_payment()andprocess_payment()— the caller's token registry (not the SDK's) decides which token contract is settled and which EIP-712 domain ({"name", "version"}) goes to the facilitator viaextra. Unblocks non-USDC settles where the token is not in the SDK registry or the registries drift - Added:
eip712_domainoverride oncreate_authorization()— injects the domain into the SIGNED digest (and into the non-USDCtoken.eip712block), so the signature verifies against the domain the verifier resolves. Partial domains raiseValueErrorbefore signing - Added: opt-in settle retry —
settle_payment(..., retry=True)(defaultFalse, single attempt exactly as before). Up to 3 attempts, exponential backoff (1s, 2s), retrying ONLY transient transport errors and 5xx; NEVER a 4xx, a business failure inside a 2xx, or a 5xx whose body already carries a transaction hash (anti-double-settle guard, ported from Execution Market's facilitator retry policy) - Added:
try_settle_payment()— non-raising settle returning{"success", "tx_hash", "error"};success=Falsewithtx_hashset is the double-settle warning shape (the facilitator broadcast the tx despite the error status — verify on-chain, do NOT re-send) - Purely additive — every new parameter defaults to the previous behavior; the default wire request is byte-identical
v0.35.0 (2026-08-02)
- Added:
uvd_x402_sdk.escrow_signing— escrow pre-auth builder for sign-on-assignment marketplaces (Execution Market ADR-002):build_escrow_pre_auth()signs theReceiveWithAuthorizationescrow lock and returns the raw JSONX-Payment-Authheader value;compute_escrow_nonce()mirrorsAuthCaptureEscrow.getHash(payer zeroed, receiver INCLUDED — the signature commits to the worker) - Wrapper and nonce pinned by golden vectors (
tests/fixtures/escrow-preauth.json, byte-identical copy of Execution Market's F0-1 shared fixture, also consumed by the EM web/mobile/plugin-SDK suites) - Purely additive — importable on a base install (eth-abi/eth-utils only required at signing time);
EnvKeyAdapter.sign_eip3009andAdvancedEscrowClient._compute_noncebehavior unchanged, their digest/nonce parity is now pinned by tests
v0.34.0 (2026-08-02)
- Added:
uvd_x402_sdk.erc8128— ERC-8128 Signed HTTP Requests (RFC 9421) with anyWalletAdapter:sign_request()buildsSignature/Signature-Input/Content-Digestheaders,fetch_nonce()gets the single-use server nonce - Wire format pinned by golden vectors (
tests/fixtures/erc8128.json, byte-identical copy of Execution Market's F3-1 conformance fixture):alg="eip191"emitted, keyid always lowercase, params in the ordercreated;expires;nonce;keyid;alg - Purely additive — importable on a base install (no
eth-accountrequired until you instantiate an adapter)
v0.27.0 (2026-07-27)
- Fixed:
BazaarClient.list_resources()raisedValidationErroragainst the live registry.firstSeen/lastSeenare epoch integers on the wire but the model declared themstr, so every call failed regardless oflimit. Timestamps are now epochintand coerce from int, float, numeric string, ISO-8601 string ordatetime - Added
first_seen_at/last_seen_at/last_updated_athelpers returning timezone-aware UTCdatetime - Added
lastUpdated, which the registry has always sent and the model dropped - Added:
health(DiscoveryHealth:status,last_checked,http_status,latency_ms) andcuration(DiscoveryCuration:tier,label). These are the fields that make the registry usable -- without them you cannot sort by reachable or by vetted -- andmodel_dump()was discarding them - Added
DiscoveryResource.is_aliveand.tiershortcuts list_resources()now exposes every server-side filter:provider,tag,source,source_facilitator,health,tierand free-textq.qis validated againstMAX_SEARCH_LEN(128) andhealth/tieragainstHEALTH_FILTERS/TIER_FILTERSbefore the request goes out- Discovery models now keep unmodelled server fields instead of silently dropping them
paginationis a typedDiscoveryPaginationand still supportspagination["total"]- Added
tests/test_discovery.py, a contract test pinned to a verbatim live registry page
Reported by KarmaCadabra against uvd-x402-sdk 0.26.0.
v0.26.0 (2026-07-21)
- Added Robinhood Chain support:
robinhood(chain ID 4663) androbinhood-testnet(chain ID 46630), Arbitrum Orbit L2s with ETH gas - Settlement stablecoin is Paxos USDG (Global Dollar), NOT USDC -- there is no USDC on Robinhood Chain
- New
usdgtoken type; USDG uses 6 decimals and EIP-712 domain{name: "Global Dollar", version: "1"} - USDG's on-chain
version()reverts, so the SDK always carries the domain viaextra(never resolved on-chain) - Added
default_tokenfield toNetworkConfigso a network can declare a primary settlement asset other than USDC - CAIP-2 mappings:
eip155:4663(mainnet) andeip155:46630(testnet) - Now supports 25 blockchains across 7 network families
v0.24.0 (2026-05-30)
- Added XRP Ledger support:
xrpl-mainnetandxrpl-testnet(native XRP, 6 decimals/drops) - New
NetworkType.XRPLandnetworks/xrpl.py(drops/XRP helpers, address validation) - XRPL settles via pre-signed Payment transaction blobs; the facilitator submits and pays the fee
- Added
recipient_xrpltoX402Configand theX402_RECIPIENT_XRPLenv var - Exported
XRPL_FEE_PAYER_MAINNET/XRPL_FEE_PAYER_TESTNET - XRPL has no CAIP-2 form; only the v1 network strings are recognized
- Now supports 23 blockchains across 7 network families
v0.22.0 (2026-04-05)
- Commerce Scheme:
PaymentPayload,PaymentRequirements, andPaymentRequirementsV2now acceptscheme: "exact" | "escrow" | "commerce"- Aligns with facilitator v1.43.0 which serves
"commerce"as alias for"escrow"(Execution Market / arbiter integrations) - Default scheme remains
"exact"-- no breaking changes - Updated scheme validator with clear error messages
- Aligns with facilitator v1.43.0 which serves
v0.21.0 (2026-04-03)
- AdvancedEscrowClient WalletAdapter:
AdvancedEscrowClientnow acceptsWalletAdapterprotocol for signing- Enables Open Wallet Standard (OWS) integration for escrow operations
- Backward compatible: still supports direct private key usage
v0.20.0 (2026-04-02)
- WalletAdapter Protocol: Abstract wallet interface for signing operations (
wallet.py)WalletAdapter- runtime-checkable Protocol for any wallet backendEnvKeyAdapter- raw private key from env var or direct paramOWSWalletAdapter- stub for Open Wallet Standard (not yet on PyPI)EIP3009Params,EIP3009Authorization,SignedTypedDataTypedDict types- Auto-detects USDC contract addresses and EIP-712 domain names per network
- Uses proven
encode_typed_data()+sign_message()signing pattern
- New
[wallet]install extra:pip install uvd-x402-sdk[wallet](eth-account>=0.11.0) - eth-account bumped to
>=0.11.0across all extras (signer,wallet,web3)
v0.19.4 (2026-03-29)
- ERC-8004: Added
get_identity_by_owner()method for looking up agent identity by owner address - ERC-8004: Added documentation for register idempotency behavior
v0.19.3 (2026-03-29)
- ERC-8004: Added SKALE to ERC-8004 networks (20 networks total: 18 EVM + Solana + Solana-devnet)
v0.19.2 (2026-03-27)
- Escrow: Added
OPERATOR_ABI_V2withbytes dataparameter for CREATE3 chains - Escrow: Updated SKALE default operator to EM-deployed address
v0.18.0 (2026-03-25)
- Server-Side Signing: New
X402Client.connect_with_private_key()for backend EIP-3009 signing- Creates an EVM signer from a private key without browser wallet
- Requires
pip install uvd-x402-sdk[signer](onlyeth-account, not fullweb3)
- EVM-only guard:
create_authorization()now validates chain is EVM type before signing
v0.17.0 (2026-03-22)
- SKALE Base Network: Added
skale-base(mainnet, chainId 1187947933) andskale-base-sepolia(testnet, chainId 324705682)- Gasless transactions (CREDIT gas token), legacy tx only (no EIP-1559)
- EIP-712 domain name:
"Bridged USDC (SKALE Bridge)"
- SKALE Base Escrow: Added escrow support via CREATE3 contracts
v0.16.0 (2026-03-03)
- Bazaar Discovery: New
BazaarClientfor resource registration and discoverylist_resources()with pagination, category, and network filteringregister_resource()for publishing paid resources to the BazaarDiscoveryResourceandDiscoveryResponsePydantic models
- Facilitator Info Endpoints: New methods on
X402Clientget_version()- query facilitator version (GET /version)get_supported()- list supported networks/schemes (GET /supported)get_blacklist()- check blocked/sanctioned addresses (GET /blacklist)health_check()- check facilitator availability (GET /health)
- Escrow State Queries: New
get_escrow_state()method onEscrowClient- Query on-chain escrow state via
POST /escrow/state - Read status, balance, timestamps without settlement
- Query on-chain escrow state via
- Bug Fix: Fixed
negotiate_accepts()referencing undefinedself.facilitator_urlandself.timeout- Now correctly uses
self.config.facilitator_urland the HTTP client pool
- Now correctly uses
v0.15.0 (2026-03-03)
- ERC-8004 Solana Support: Full integration with QuantuLabs 8004-solana Anchor program + ATOM Engine
AgentIdtype alias (Union[int, str]) for dual EVM/Solana agent IDs- Solana and Solana-devnet added to
Erc8004Network(18 networks total) - Solana program ID constants (
agent_registry_program,atom_engine_program) - All ERC-8004 methods now accept
AgentId(int for EVM, string for Solana) seal_hashparameter added torevoke_feedback()andappend_response()(SEAL v1 support)
/acceptsNegotiation: Newnegotiate_accepts()method onX402Client- Sends merchant payment requirements to facilitator POST
/accepts - Returns enriched requirements with feePayer, tokens, escrow config
- Faremeter middleware compatibility
- Sends merchant payment requirements to facilitator POST
- Solana Smart Wallet Support: Transparent CPI inner instruction scanning on server side
v0.14.0 (2026-02-20)
- Per-Network Settle Timeout: Different timeout per chain (L1 vs L2)
- On-Chain Fallback: Post-timeout on-chain state check before returning failure
- Escrow: Updated Ethereum mainnet contract addresses from Ali's redeploy
- Escrow: Fixed Optimism
token_collectorandprotocol_fee_configaddresses (corrected to CREATE2)
v0.13.2 (2026-02-13)
- Escrow: Dynamic EIP-712 domain resolution per-chain (was hardcoded "USD Coin"/"2")
- Escrow: Added Optimism (chain_id=10) to
ESCROW_CONTRACTS - Fix: HexBytes compatibility with web3 >= 6.x and eth_account >= 0.10.0
v0.6.0 (2026-01-30)
- ERC-8004 Trustless Agents: Full client for on-chain reputation system
Erc8004Clientclass with identity, reputation, and feedback methodsappend_response()method for agents to respond to feedbackProofOfPaymentmodel for reputation submission authorizationbuild_erc8004_payment_requirements()helper
- Escrow & Refund Support: Complete escrow payment flow
EscrowClientclass with create, release, refund, dispute methodsEscrowPayment,RefundRequest,Disputemodels- Helper functions:
can_release_escrow(),can_refund_escrow(), etc.
- New Networks: Scroll (534352) and SKALE (1187947933, testnet: 324705682)
- SKALE is gasless L3 with sFUEL
- Scroll is zkEVM Layer 2
- SDK now supports 21 blockchain networks
v0.5.6 (2025-12-31)
- Added
SuiPayloadContentPydantic model for Sui sponsored transactions - Added
coinObjectIdas required field (CRITICAL for facilitator deserialization) - Added
get_sui_payload()method toPaymentPayload - Updated
validate_sui_payload()to requirecoinObjectId
v0.5.5 (2025-12-30)
- Added AUSD (Agora USD) support for Sui mainnet
- Added
SUI_AUSD_COIN_TYPE_MAINNETconstant - Added
get_sui_ausd_coin_type()helper function
v0.5.4 (2025-12-30)
- Sui Blockchain Support: Added Sui mainnet and testnet networks
- Added
NetworkType.SUIfor Sui Move VM chains - Added
SUI_FEE_PAYER_MAINNETandSUI_FEE_PAYER_TESTNETsponsor addresses - Added CAIP-2 support for
sui:mainnetandsui:testnet - Added Sui-specific utilities:
validate_sui_payload(),is_valid_sui_address(),is_valid_sui_coin_type() - SDK now supports 18 blockchain networks
v0.5.3 (2025-12-27)
- Documentation updates for Algorand support
- Updated README with facilitator addresses and changelog
v0.5.2 (2025-12-26)
- Added EVM facilitator addresses for reference
EVM_FACILITATOR_MAINNET: 0x103040545AC5031A11E8C03dd11324C7333a13C7EVM_FACILITATOR_TESTNET: 0x34033041a5944B8F10f8E4D8496Bfb84f1A293A8
v0.5.1 (2025-12-26)
- Changed default Algorand mainnet network name from
algorand-mainnettoalgorand - Aligns with facilitator v1.9.5+ which now uses
algorandas the primary network identifier
v0.5.0 (2025-12-26)
- Facilitator Module: Added
facilitator.pywith all fee payer addresses embedded as constants - SDK users no longer need to configure facilitator addresses manually
- Added constants:
ALGORAND_FEE_PAYER_MAINNET,SOLANA_FEE_PAYER_MAINNET,NEAR_FEE_PAYER_MAINNET,STELLAR_FEE_PAYER_MAINNET, etc. - Added helper functions:
get_fee_payer(),requires_fee_payer(),build_payment_info() - Network-specific helpers:
get_algorand_fee_payer(),get_svm_fee_payer(),get_near_fee_payer(),get_stellar_fee_payer()
v0.4.2 (2025-12-26)
- Algorand Atomic Group Fix: Rewrote Algorand payload format to use GoPlausible x402-avm atomic group spec
- New
AlgorandPaymentPayloaddataclass withpaymentIndexandpaymentGroupfields - Added
build_atomic_group()helper for constructing two-transaction atomic groups - Added
validate_algorand_payload()for payload validation - Added
build_x402_payment_request()for building complete x402 requests
v0.4.1 (2025-12-26)
- Added AUSD (Agora USD) support on Solana using Token2022 program
- Added
TOKEN_2022_PROGRAM_IDconstant - Added
get_token_program_id()andis_token_2022()helpers
v0.4.0 (2025-12-26)
- Algorand Support: Added Algorand mainnet and testnet networks
- Added
ALGORANDNetworkType - Added
algorandoptional dependency (py-algorand-sdk>=2.0.0) - SDK now supports 16 blockchain networks
v0.3.4 (2025-12-22)
- Added USDT support (USDT0 omnichain via LayerZero) on Ethereum, Arbitrum, Optimism, Avalanche, Polygon
- SDK now supports 5 stablecoins: USDC, EURC, AUSD, PYUSD, USDT
v0.3.3 (2025-12-22)
- Fixed EIP-712 domain names: AUSD uses "Agora Dollar" (not "Agora USD")
- Fixed EURC domain name on Ethereum/Avalanche: "Euro Coin" (not "EURC")
v0.3.2 (2025-12-21)
- Added critical implementation notes for multi-token support:
- EIP-712 domain names vary by chain (e.g., EURC is "Euro Coin" on Ethereum but "EURC" on Base)
- PYUSD uses v,r,s signature variant (Paxos implementation)
- Token info must be passed to facilitator via
extrafield
v0.3.1 (2025-12-21)
- Removed GHO and crvUSD token support (not EIP-3009 compatible)
- SDK now supports 4 stablecoins: USDC, EURC, AUSD, PYUSD
v0.3.0 (2025-12-20)
- Multi-Stablecoin Support: Added support for 4 stablecoins on EVM chains
- USDC (all EVM chains)
- EURC (Ethereum, Base, Avalanche)
- AUSD (Ethereum, Arbitrum, Avalanche, Polygon, Monad)
- PYUSD (Ethereum)
- Added
TokenTypeliteral type andTokenConfigdataclass - Added token helper functions:
get_token_config(),get_supported_tokens(),is_token_supported(),get_networks_by_token() - Added
tokensfield toNetworkConfigfor multi-token configurations - Updated EVM network configurations with token contract addresses and EIP-712 domain parameters
v0.2.2 (2025-12-16)
- Added Security section to documentation
- Added Error Codes table
- Updated links to new GitHub repository
- Synced documentation with TypeScript SDK
v0.2.1 (2025-12-16)
- Removed BSC network (doesn't support ERC-3009)
- Added GitHub Actions workflow for PyPI publishing
- Updated to 14 supported networks
v0.2.0 (2025-12-15)
- Added NEAR Protocol support with NEP-366 meta-transactions
- Added Fogo SVM chain support
- Added x402 v2 protocol support with CAIP-2 network identifiers
- Added
acceptsarray for multi-network payment options - Refactored Solana to generic SVM type (supports Solana, Fogo, future SVM chains)
- Added CAIP-2 parsing utilities (
parse_caip2_network,to_caip2_network) - Added
MultiPaymentConfigfor multi-network recipient configuration - Added
Payment402BuilderV2for v2 response construction
v0.1.0 (2025-12-01)
- Initial release
- EVM, Solana, Stellar network support
- Flask, FastAPI, Django, Lambda integrations
- Full Pydantic models
DX402 — evidence that outlives the session
x402 settles payment on-chain forever but delivers the resource once and keeps nothing. DX402 seals a copy of the response to the payer's own public key — recovered from the payment signature itself — and anchors it. No registration, no extra round trip: paying is publishing your encryption key.
pip install 'uvd-x402-sdk[dx402]'
Seller: one call
from uvd_x402_sdk import anchor_evidence, evidence_header
result = anchor_evidence(
body, # the bytes you are about to deliver
payment_id_value=payment_id, network="base", tx_hash=tx,
payer=payer_addr, payee=my_addr, payer_key=payer_pubkey,
signer=lambda digest: my_custodian.sign(digest), # a callable, not a key
)
response.headers["X-Durable-Evidence"] = evidence_header(result)
It never raises. Every failure comes back as result["skipped"], because
evidence is an addition to the payment path and must never be a gate in front of
it. An unreachable facilitator costs the receipt, never the sale.
signer takes a callable rather than a private key so a custodian can sign:
it receives the 32-byte digest and returns a signature without the seed ever
leaving it.
Buyer: come back months later
from uvd_x402_sdk import recover_evidence, evidence_from_headers
evidence = evidence_from_headers(response.headers)
body = recover_evidence(evidence, my_private_key)
This needs permission from nobody. The ciphertext was sealed to the wallet that
paid, so recovery is arithmetic rather than an access-control decision anyone
could refuse. The contentHash check runs automatically and raises on
mismatch — it is what catches a seller who anchored something other than what it
served.
verified vs signed — read this before you branch on either
Since facilitator 1.87.0 a signature alone does not make an anchor final:
| field | means | supersedable by |
|---|---|---|
verified: true |
the chain confirmed this address is the payee | nothing — final |
signed: true |
the claimant controls the address it declared | a verified anchor |
| neither | anyone could have written it | either of the above |
To reach verified you must send proof_of_payment. Without it the facilitator
has checked no chain and answers notVerifiedReason: "dx402_proof_missing" —
your signature was still accepted (signed: true), authorship simply was not
certified.
Why the split: verified was previously decided against the payee field in
the request, which the caller supplies. Proving "I control the address I typed
into my own request" was enough to own a stranger's evidence permanently.
Choosing where evidence is stored
from uvd_x402_sdk import available_backends
for b in available_backends("https://facilitator.ultravioletadao.xyz"):
print(b["id"], b["retention"], "deletable" if b["revocable"] else "IRREVERSIBLE")
anchor_evidence(body, ..., storage="ipfs-private")
Ask rather than assume: what exists depends on the deployment, and you may be
pointed at a facilitator that is not ours. revocable: False means the
retentionUntil in the signed receipt cannot be honoured — on public IPFS,
unpinning removes the facilitator's copy, not the network's.
Runnable examples
Not snippets — files, and CI runs them:
| file | shows |
|---|---|
examples/dx402/seller_anchor.py |
the whole seller side in one call |
examples/dx402/buyer_recover.py |
recovery, tamper detection, and that another wallet cannot open it |
examples/dx402/verified_anchor.py |
reaching verified: true with proof_of_payment |
examples/dx402/choose_storage.py |
discovering backends and what each one promises |
Limits
- Inline anchors cap at 64 KiB of request (~47 KB of plaintext); the SDK
returns
skipped: "too_large"before touching the network. - Anchoring with
retention: permanentis irrevocable. - On Solana,
verifiedis not reachable yet — the on-chain gate cannot read that payment, sosigned: trueis the honest maximum.
Full guide: DX402.md
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file uvd_x402_sdk-0.80.0.tar.gz.
File metadata
- Download URL: uvd_x402_sdk-0.80.0.tar.gz
- Upload date:
- Size: 443.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.16
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e04a5c30a595d158a52a2e3612dcdca18de3a1468815beefb6c89eefe415d427
|
|
| MD5 |
dce66629e045ffbc47c22ae299c5c921
|
|
| BLAKE2b-256 |
a9bed79e1907925d65cffca5210b846f3b5057f823d898ad05550b1dfd5483bc
|
File details
Details for the file uvd_x402_sdk-0.80.0-py3-none-any.whl.
File metadata
- Download URL: uvd_x402_sdk-0.80.0-py3-none-any.whl
- Upload date:
- Size: 280.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.16
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6cc1f59c4cbeb1870c722d386474094e15dea0f8278de5b1c3947e4b5ac92dfd
|
|
| MD5 |
64b0cfd377d27ca9f0ca4fbfb5eade03
|
|
| BLAKE2b-256 |
fafb40166fa6db2bc31e01bc17359fcf660f0f9ce5837f6a33b3de683ae27ee9
|