Metadata-Version: 2.1
Name: uvd-x402-sdk
Version: 0.81.0
Summary: Python SDK for x402 payments - gasless crypto payments across 25 blockchains (including Robinhood Chain, XRPL, Scroll, SKALE). Features: ERC-8004 Trustless Agents, Escrow/Refunds, multi-stablecoin (USDC, EURC, AUSD, PYUSD, USDT, USDG)
Author-email: Ultravioleta DAO <dev@ultravioletadao.xyz>
Project-URL: Homepage, https://github.com/UltravioletaDAO/uvd-x402-sdk-python
Project-URL: Documentation, https://docs.ultravioletadao.xyz/x402-sdk
Project-URL: Repository, https://github.com/UltravioletaDAO/uvd-x402-sdk-python
Project-URL: Issues, https://github.com/UltravioletaDAO/uvd-x402-sdk-python/issues
Keywords: x402,payments,crypto,usdc,eurc,stablecoin,web3,evm,solana,near,stellar,algorand,sui,xrpl,xrp,scroll,skale,robinhood,usdg,facilitator,gasless,eip-712,eip-3009,erc-8004,trustless-agents,escrow,refund
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.24.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: signer
Requires-Dist: eth-account>=0.11.0; extra == "signer"
Provides-Extra: dx402
Requires-Dist: cryptography>=42.0.0; extra == "dx402"
Requires-Dist: eth-utils>=4.0.0; extra == "dx402"
Requires-Dist: eth-keys>=0.5.0; extra == "dx402"
Requires-Dist: eth-hash[pycryptodome]>=0.7.0; extra == "dx402"
Provides-Extra: wallet
Requires-Dist: eth-account>=0.11.0; extra == "wallet"
Provides-Extra: web3
Requires-Dist: web3>=6.0.0; extra == "web3"
Requires-Dist: eth-account>=0.11.0; extra == "web3"
Provides-Extra: escrow
Requires-Dist: web3>=6.0.0; extra == "escrow"
Requires-Dist: eth-account>=0.11.0; extra == "escrow"
Provides-Extra: solana
Requires-Dist: cryptography>=42.0.0; extra == "solana"
Provides-Extra: flask
Requires-Dist: flask>=2.0.0; extra == "flask"
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
Requires-Dist: starlette>=0.27.0; extra == "fastapi"
Provides-Extra: django
Requires-Dist: django>=4.0.0; extra == "django"
Provides-Extra: aws
Requires-Dist: boto3>=1.26.0; extra == "aws"
Provides-Extra: algorand
Requires-Dist: py-algorand-sdk>=2.0.0; extra == "algorand"
Provides-Extra: all
Requires-Dist: uvd-x402-sdk[algorand,aws,django,escrow,fastapi,flask,signer,solana,wallet,web3]; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: black>=23.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Requires-Dist: httpx>=0.24.0; extra == "dev"

# 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
- **`/accepts` Negotiation**: 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_v2` remain 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-Auth` header) with any WalletAdapter, no web3 required
- **Signed escrow lifecycle orders**: `build_lifecycle_auth()` — sign the EIP-712 order that entitles a `release` / `refundInEscrow`, and pass a `lifecycle_signer` to `release_via_facilitator()` / `refund_via_facilitator()`, or a `lifecycle_auth` when someone else already signed it

## Quick Start (5 Lines)

```python
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:

```python
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:

```python
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"}` in `PaymentRequirements.extra`. The SDK carries this automatically for the `robinhood` / `robinhood-testnet` networks.

## Installation

```bash
# 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

```python
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

```python
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

```python
# 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

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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]`)

```python
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.

```python
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

```python
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.

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```bash
# 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

```python
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.

```python
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:

1. **Not configured = nothing changes.** Without `facilitator_by_network` every
   network resolves to `facilitator_url`, exactly as before.
2. **It never guesses.** A network that is not in the table raises
   `ConfigurationError` instead of falling back to `facilitator_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}`.
3. **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 `"*"`. Pass
   `verify_facilitator_support=True` to also probe each facilitator's
   `GET /supported` at construction and refuse a route the facilitator itself
   does not advertise (available on demand as `client.verify_routes()`).

From the environment, as a JSON object:

```bash
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:

```python
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:

```python
from uvd_x402_sdk import (
    EVM_FACILITATOR_MAINNET,  # 0x103040545AC5031A11E8C03dd11324C7333a13C7
    EVM_FACILITATOR_TESTNET,  # 0x34033041a5944B8F10f8E4D8496Bfb84f1A293A8
)
```

### Helper Functions

```python
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

```python
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

```python
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:

```python
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.

```python
# 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 of `transferWithAuthorization_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`:

```python
# 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):

```python
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)

```python
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=false` inside 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

```python
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

```python
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

```python
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.

```python
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.

```python
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.

### The same thing on Solana, and it needs no delegate

Solana has the identical defect and a shorter fix. Account 0 of the program's
`give_feedback` instruction is already declared `[signer, writable] client
(feedback author / fee payer)`, and `POST /feedback` puts the **facilitator's**
keypair there. Since Solana takes several signers per transaction natively, the
rater can simply sign as `client` while the facilitator stays the fee payer --
no delegation, no contract in between.

Which is why this is a **sibling rail**, not one more network on
`RELAYED_FEEDBACK_NETWORKS`: that set means "a `FeedbackDelegate` is deployed
here", and `prepare_relayed_feedback()` builds the **EVM** URL
`/feedback/evm/prepare` out of it. Solana routes through `SOLANA_FEEDBACK_NETWORKS` and
`/feedback/solana/*` instead.

```bash
pip install uvd-x402-sdk[solana]   # ed25519 signing; no RPC client, no web3
```

```python
import os

from uvd_x402_sdk import (
    Ed25519Signer,
    Erc8004Client,
    sign_solana_feedback_transaction,
    supports_solana_feedback,
)

signer = Ed25519Signer(os.environ["RATER_SECRET_KEY"])  # 32/64 bytes or base58

async with Erc8004Client() as client:
    if not supports_solana_feedback("solana"):
        ...  # fall back to submit_feedback(); the facilitator is the author

    # 1. Ask for the transaction. Free, writes nothing, and the `client`
    #    account it builds is the RATER.
    prep = await client.prepare_solana_feedback(
        network="solana",
        agent_id="247Y4QLwz9ZbcuHR2nX2EQLZHCsMs1GTqvgd6fpdn85Q",  # asset pubkey
        rater=signer.pubkey,
        value=87,
        score=95,          # without it the ATOM Engine records but scores nothing
        tag1="quality",
    )

    # 2. Sign it. The message is carried through byte for byte -- the
    #    facilitator rebuilds it and refuses anything that is not identical.
    signed = sign_solana_feedback_transaction(prep.transaction, signer.pubkey, signer)

    # 3. Send it back with the SAME feedback parameters. The facilitator
    #    verifies the rater's signature, then adds its own and pays the fee.
    result = await client.submit_solana_feedback(
        network="solana",
        agent_id="247Y4QLwz9ZbcuHR2nX2EQLZHCsMs1GTqvgd6fpdn85Q",
        rater=signer.pubkey,
        value=87,
        score=95,
        tag1="quality",
        transaction=signed,
    )
```

The fee payer's signature slot is left empty on purpose: that one is the
facilitator's, and it fills it only after the rater's has verified -- so a
transaction the network would reject never costs a fee.

`prepare` pins the transaction to a recent blockhash, and Solana blockhashes
expire in about a minute of slots. Prepare when the rater is ready to sign,
not before -- and once `lastValidBlockHeight` is past, **call `prepare` again**.
The window is retryable, the signed transaction is not replayable: the
blockhash lives inside the message, so a new one has to be signed.

`Ed25519Signer` is a convenience, not a requirement: anything with a base58
`pubkey` and a `sign_message(bytes) -> bytes` satisfies `SolanaSigner`, so a
browser wallet, a custodian or an HSM plugs in unchanged.

Available on `solana` and `solana-devnet`. Requires facilitator **v1.74.0+**
(the release that first served both routes); measured live on **v2.16.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.

```bash
pip install uvd-x402-sdk[signer]
```

```python
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.

```python
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.

```python
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.

```python
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).

```bash
pip install uvd-x402-sdk[wallet]
```

```python
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:

```python
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](https://eip.tools/eip/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.

```python
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`.

```python
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).

```python
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:

```python
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.salt` is `bytes32` on the wire and `uint256` in the signature.**
  The facilitator converts it (`U256::from_be_bytes`); signing it as a string
  produces a different digest and a silent `bad_signature`. The SDK converts it
  for you.
- **`amount` is signed.** Signing `max_amount` and submitting a partial release
  is an order that does not verify — and a partial is the normal case for a
  stream.
- **`deadline` has a 900 s ceiling** (`deadline_too_far`). The default signs
  `now + 600`, which is what keeps a facilitator clock a few seconds behind
  yours from deciding the verdict.
- **`nonce` is 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 `getHash` and 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:

```python
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)
```

- **`deadline` and `nonce` are not passed separately.** They are read out of
  the `typed_data` that was signed. Declaring them alongside would let the
  wire announce one window while the signature covers another — a
  `bad_signature` neither side can name.
- **The signature is verified against `signer` before 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 answers `bad_signature` to that and does not say which of the
  two was wrong; `lifecycle_auth_from_signature` does. (It needs
  `eth-account` for the recovery — `pip install uvd-x402-sdk[signer]`.)
- **`lifecycle_signer` and `lifecycle_auth` are 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 on `release` that 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](#choosing-the-envelope-x402_version) 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:

```python
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"`:

```python
# 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.

```python
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`)

```python
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_transactions` has **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.

```python
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.** `network` is the name `/supported` uses,
> which is not always the alias you may *send*. `skale` is accepted inbound, but
> events always say `skale-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.

```python
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.

```python
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 (`validBefore` in 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 `x402Version` is 1 or 2

### Debug Logging

```python
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("uvd_x402_sdk").setLevel(logging.DEBUG)
```

---

## Development

```bash
# 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

- [x402 Protocol](https://x402.org)
- [Ultravioleta DAO](https://ultravioletadao.xyz)
- [402milly](https://402milly.xyz)
- [GitHub](https://github.com/UltravioletaDAO/uvd-x402-sdk-python)
- [PyPI](https://pypi.org/project/uvd-x402-sdk/)
- [TypeScript SDK](https://github.com/UltravioletaDAO/uvd-x402-sdk-typescript)

---

## License

MIT License - see LICENSE file.

---

## Changelog

### v0.81.0 (2026-09-07)
- **Added: the rater can now author their own rating on Solana.** `prepare_solana_feedback()` + `sign_solana_feedback_transaction()` + `submit_solana_feedback()` drive the facilitator's `/feedback/solana/prepare` and `/feedback/solana/submit`, live on the deployed facilitator since **v1.74.0** (measured today on **v2.16.0**) and until now with **no client on either SDK**. The server half has existed since 2026-08-13 (`x402-rs`, `src/erc8004/solana.rs`); nothing could call it
- **Why it matters**: account 0 of the program's `give_feedback` instruction is declared `[signer, writable] client (feedback author / fee payer)`, and `POST /feedback` puts the FACILITATOR's keypair there. Same defect the EIP-7702 rail fixes on EVM -- the chain records the service, not the person. Solana takes several signers per transaction natively, so the fix needs no delegate and no program change: the rater signs as `client`, the facilitator stays the fee payer
- **`SOLANA_FEEDBACK_NETWORKS` / `supports_solana_feedback()` are a SIBLING of `RELAYED_FEEDBACK_NETWORKS`, never an addition to it.** That frozenset means "Execution Market deployed a `FeedbackDelegate` here and the facilitator verified it on-chain", and `prepare_relayed_feedback()` builds the EVM URL `/feedback/evm/prepare` from it -- a `solana` entry there routes the call to the EVM URL, which answers 400. Pinned by a test, and the delegate tests are untouched and still green
- **The message is never re-serialised.** `sign_solana_feedback_transaction()` carries the exact bytes that came off the wire and rewrites only the signature array, because `accept_rater_signed_transaction` compares the submitted message against the one it rebuilds and refuses a mismatch (`submitted transaction does not match the one this facilitator built`). A client that decoded to structs and re-encoded would look correct and fail there, with nothing on its own side to point at
- **Added: `Ed25519Signer`** (`pip install 'uvd-x402-sdk[solana]'`) -- a `SolanaSigner` over a 32-byte seed, a 64-byte `solana-keygen` key, its JSON int array or its base58 form. The 64-byte form's public half is checked against the derived one: a key pasted a byte short still parses and then signs as somebody else. The key is never in `repr`, never in an error, never on disk
- **`SolanaSigner` is a two-member Protocol**, so a browser wallet, a custodian or an HSM plugs in with no adapter
- **Everything that can go wrong fails before the network**: a signer holding another key, a rater that is not a required signer, a versioned (v0) message, a truncated blob, a header that disagrees with the signature array, a signature of the wrong length. From a facilitator 400 those are indistinguishable, and one of them -- the rater in a non-signer slot -- is a rating signed by nobody
- **Wire pinned against the LIVE facilitator.** `tests/fixtures/solana-feedback-prepare.json` is a capture from `https://facilitator.ultravioletadao.xyz` (v2.16.0, 2026-09-07): two signature slots both empty, fee payer at account 0, rater at account 1, 11 accounts. The decode -> encode round trip is asserted byte-identical, and the rater's signature is pinned by value (ed25519 is deterministic), so a change in *what* gets signed goes red instead of producing a well-formed signature over the wrong thing
- 968 tests pass (931 before, 37 added, none lost). `submit_feedback()` and the EVM relay rail are byte-for-byte unchanged

### v0.80.0 (2026-09-07)
- **Fixed: the lifecycle order document could not be signed by a browser.** `build_lifecycle_typed_data()` emitted a document `viem` refuses, and the consumer was patching it by hand (`execution-market:mcp_server/integrations/x402/lifecycle_auth.py`, with its own "[DIVERGENCIA DE LOS SDK, para upstream]" comment). Three divergences, not one: a missing `primaryType`, a `nonce` as `bytes` (`json.dumps` raises, so the document could not even reach the browser), and every uint as a Python int
- **The third one did not fail -- it signed the wrong struct.** A real 32-byte `salt` is destroyed by `JSON.parse`: measured with `0xab*32`, Python signed `0x15a8587e...` and viem, reading that same document, `0x4c88c56a...`. Two valid signatures over two different structs, with no error anywhere. The pinned vector missed it because its salt (12345) fits in a double
- **No digest moved.** EIP-712 hashes domain, types and message; `eth-account` decodes a uint from an int or a decimal string to the same value. The shared vector's signature is byte-identical before and after (`0x78fe14...3a71c`, pinned in `tests/test_lifecycle_auth.py`)
- **`lifecycle_auth_from_signature` rejects a wrong `primaryType` and tolerates an absent one** -- every document emitted by 0.78.0 and 0.79.0 lacks it. The TypeScript twin emitted all three correctly from its first day; Python was the one that was wrong
- The cross-language gate now compares the DOCUMENT, not just the signature, across a real `json.dumps` -> `JSON.parse` boundary

### v0.79.0 (2026-09-06)
- **Added: `release_via_facilitator()` / `refund_via_facilitator()` accept `lifecycle_auth=`** — a lifecycle order **already signed by someone else**, attached to `payload.lifecycleAuth` verbatim. 0.78.0 shipped only `lifecycle_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 un `lifecycle_auth=`, no puede transportar la orden de un tercero"* — their side is already merged behind `EM_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. `deadline` and `nonce` are 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 `signer` before 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 answers `bad_signature` and 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_signer` and `lifecycle_auth` are 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 on `release` that 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_signature` on a signature over the same typed data equals `build_lifecycle_auth` for 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_auth` defaults to `None` and, 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 `release` and `refundInEscrow`.** `build_lifecycle_auth()` produces the EIP-712 order the facilitator verifies, and `release_via_facilitator()` / `refund_via_facilitator()` take a `lifecycle_signer` that 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): a `LifecycleOrder(string action, uint256 amount, uint256 deadline, bytes32 nonce, PaymentInfo paymentInfo)` under the domain `{"x402 escrow lifecycle", "1", chainId}` — no `verifyingContract`, because `paymentInfo.operator` already travels inside the signed struct. `PaymentInfo` is the `AuthCaptureEscrow` type 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 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 refund before expiry (that is the chargeback)
- **Backward compatible, by design.** `lifecycle_signer` is optional at every layer and defaults to `None`; without it the request goes out byte-identical to before, which is what the facilitator's `off` and `log` modes still accept. The rollout is the facilitator's `ESCROW_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 `enforce` each one is stuck money and the caller has no access to the facilitator's log: a `deadline` in the past (`expired`), one past the 900 s ceiling (`deadline_too_far` — the default signs `now + 600`, so a facilitator clock a few seconds behind yours does not decide the verdict), and a `paymentInfo` missing a field, which is refused by name instead of being defaulted into a signature over a struct that is not the one submitted
- **`paymentInfo.salt` is `bytes32` on the wire and `uint256` in the signature** (the facilitator converts it with `U256::from_be_bytes`). Signing it as a hex string produces a different digest and a `bad_signature` whose only symptom is that no order ever verifies. The SDK converts it
- **The amount signed is the amount submitted.** Signing `max_amount` while 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 `log` mode (`GET /settle` → `"escrowLifecycleAuth":"log"`), on base-sepolia, with no funds and no transaction: a payer-signed release logged `verdict="ok"` (2026-09-06T01:27:49.199084Z) — the first one ever — and a stranger-signed one logged `verdict="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 in `docs/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 produced `maxAmountRequired = 1000000` drops — **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 by `10**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` (default `True`, so the 23 other networks are byte-for-byte unchanged). XRPL mainnet and testnet carry `False`, and both `get_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 at `GET /supported` for a pegged token — a refusal that does not say where to look just moves the dead end
- **`token_decimals` does not open this door.** It fixes SCALE and the defect is UNIT: six decimals of XRP are still XRP. The way through is naming an `asset` — the facilitator settles a dollar-pegged USDC on XRPL (issuer `rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE`, Circle) — with its `token_decimals`, or pricing the call in drops yourself
- **Fixed**: **XRPL mainnet is `xrpl`, not `xrpl-mainnet`.** The facilitator advertises `xrpl` in `GET /supported` and prints `xrpl` everywhere (`x402-rs/src/network.rs:189`); it takes `xrpl-mainnet` only as an informal `FromStr` alias, 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, so `X402Client(..., 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-mainnet` keeps resolving** as an alias (`get_network`, `normalize_network`, `X402Config.supported_networks`), through the same `_NETWORK_ALIASES` table that already carried `skale`. 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 `main` that the PR would have inherited — see `docs/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; `ruff` and `mypy` counts 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_body` was 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_id` and `.error_code`, repeated 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. `try_settle_payment()` returns them as data (`payment_id`, `error_code`; both keys always present, `None` on 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__` computed `retryable` purely from the status (`None | 429 | >=500`) without reading the body — the same hole TypeScript found in `Erc8004LookupError`. **The status is now 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. Three signals lower it, in order of authority: an explicit `retryable: 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 the `retryable: false` that the other honours
- **Unchanged**: the transient `502` — the one with `Retry-After` and no hash — still retries with the same 3 attempts and the same clamped wait. The settle loop still never re-POSTs a `429`. A `4xx` still carries no `retryable` key in its `to_dict()`. `anti_double_settle=False` still 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-level `network` at all** (v2 moved the chain id into `accepted`), and `resolve_envelope_version()` read only `payload.network` and handed it straight to `is_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 iterable` when the network is `None`
- **The consequence was already in production**: MeshRelay's turnstile and multibrain pin `x402_version` to 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, otherwise `accepted.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 signed `payload` block 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_payload` is what flattens a v2 header)
- Purely a fix: every wire that resolved before resolves to the same version, and the v1 body from a `PaymentPayload` is 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()` and `settle_payment()` wrote `"x402Version": 1` as 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_version` was 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 same `PaymentPayload`, the client picks the envelope
- **`X402Config.x402_version` now 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. `1` or `2` pins, 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=1` to restore the previous body byte-for-byte. The v1 path itself is untouched
- Verified end-to-end against the live `/verify` with the SDK building the body, and pinned against the TypeScript SDK: the two now emit byte-identical `/verify` and `/settle` bodies 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 explicit `x402_version=2` rather 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})` (also `X402Client(...)` and `configure_x402(...)`, and the `X402_FACILITATOR_BY_NETWORK` env var as a JSON object). `verify`, `settle`, the post-timeout settle re-check and `/accepts` each 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 raises `ConfigurationError` — it is NEVER routed to `facilitator_url` silently. 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 `X402Config` constructor, not on the first payment. `X402Client(..., verify_facilitator_support=True)` additionally probes each facilitator's `GET /supported` at construction and raises if one does not advertise a network routed to it (also available on demand as `client.verify_routes()`)
- Added `X402Config.facilitator_url_for(network)`, `X402Config.facilitator_routes()`, `X402Client.facilitator_url_for()` / `verify_routes()`, and optional `network=` on `get_supported()` / `health_check()` to target a single facilitator
- Purely additive — with no `facilitator_by_network` the resolution returns `facilitator_url` for 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 `base` through Coinbase's CDP facilitator and `avalanche` through Ultravioleta's — CDP does not settle avalanche — and had to maintain a routing layer of their own on top of the SDK because a single `facilitator_url` could not express it

### v0.36.0 (2026-08-02)
- **Added**: `asset` and `eip712_domain` overrides on `settle_payment()`, `verify_payment()` and `process_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 via `extra`. Unblocks non-USDC settles where the token is not in the SDK registry or the registries drift
- **Added**: `eip712_domain` override on `create_authorization()` — injects the domain into the SIGNED digest (and into the non-USDC `token.eip712` block), so the signature verifies against the domain the verifier resolves. Partial domains raise `ValueError` before signing
- **Added**: opt-in settle retry — `settle_payment(..., retry=True)` (default `False`, 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=False` with `tx_hash` set 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 the `ReceiveWithAuthorization` escrow lock and returns the raw JSON `X-Payment-Auth` header value; `compute_escrow_nonce()` mirrors `AuthCaptureEscrow.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_eip3009` and `AdvancedEscrowClient._compute_nonce` behavior 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 any `WalletAdapter`: `sign_request()` builds `Signature` / `Signature-Input` / `Content-Digest` headers, `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 order `created;expires;nonce;keyid;alg`
- Purely additive — importable on a base install (no `eth-account` required until you instantiate an adapter)

### v0.27.0 (2026-07-27)
- **Fixed**: `BazaarClient.list_resources()` raised `ValidationError` against the live registry. `firstSeen` / `lastSeen` are epoch integers on the wire but the model declared them `str`, so *every* call failed regardless of `limit`. Timestamps are now epoch `int` and coerce from int, float, numeric string, ISO-8601 string or `datetime`
- Added `first_seen_at` / `last_seen_at` / `last_updated_at` helpers returning timezone-aware UTC `datetime`
- Added `lastUpdated`, which the registry has always sent and the model dropped
- **Added**: `health` (`DiscoveryHealth`: `status`, `last_checked`, `http_status`, `latency_ms`) and `curation` (`DiscoveryCuration`: `tier`, `label`). These are the fields that make the registry usable -- without them you cannot sort by reachable or by vetted -- and `model_dump()` was discarding them
- Added `DiscoveryResource.is_alive` and `.tier` shortcuts
- `list_resources()` now exposes every server-side filter: `provider`, `tag`, `source`, `source_facilitator`, `health`, `tier` and free-text `q`. `q` is validated against `MAX_SEARCH_LEN` (128) and `health` / `tier` against `HEALTH_FILTERS` / `TIER_FILTERS` before the request goes out
- Discovery models now keep unmodelled server fields instead of silently dropping them
- `pagination` is a typed `DiscoveryPagination` and still supports `pagination["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) and `robinhood-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 `usdg` token 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 via `extra` (never resolved on-chain)
- Added `default_token` field to `NetworkConfig` so a network can declare a primary settlement asset other than USDC
- CAIP-2 mappings: `eip155:4663` (mainnet) and `eip155:46630` (testnet)
- Now supports 25 blockchains across 7 network families

### v0.24.0 (2026-05-30)
- Added XRP Ledger support: `xrpl-mainnet` and `xrpl-testnet` (native XRP, 6 decimals/drops)
- New `NetworkType.XRPL` and `networks/xrpl.py` (drops/XRP helpers, address validation)
- XRPL settles via pre-signed Payment transaction blobs; the facilitator submits and pays the fee
- Added `recipient_xrpl` to `X402Config` and the `X402_RECIPIENT_XRPL` env 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`, and `PaymentRequirementsV2` now accept `scheme: "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

### v0.21.0 (2026-04-03)

- **AdvancedEscrowClient WalletAdapter**: `AdvancedEscrowClient` now accepts `WalletAdapter` protocol 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 backend
  - `EnvKeyAdapter` - raw private key from env var or direct param
  - `OWSWalletAdapter` - stub for Open Wallet Standard (not yet on PyPI)
  - `EIP3009Params`, `EIP3009Authorization`, `SignedTypedData` TypedDict 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.0` across 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_V2` with `bytes data` parameter 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]` (only `eth-account`, not full `web3`)
- **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) and `skale-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 `BazaarClient` for resource registration and discovery
  - `list_resources()` with pagination, category, and network filtering
  - `register_resource()` for publishing paid resources to the Bazaar
  - `DiscoveryResource` and `DiscoveryResponse` Pydantic models
- **Facilitator Info Endpoints**: New methods on `X402Client`
  - `get_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 on `EscrowClient`
  - Query on-chain escrow state via `POST /escrow/state`
  - Read status, balance, timestamps without settlement
- **Bug Fix**: Fixed `negotiate_accepts()` referencing undefined `self.facilitator_url` and `self.timeout`
  - Now correctly uses `self.config.facilitator_url` and the HTTP client pool

### v0.15.0 (2026-03-03)

- **ERC-8004 Solana Support**: Full integration with QuantuLabs 8004-solana Anchor program + ATOM Engine
  - `AgentId` type 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_hash` parameter added to `revoke_feedback()` and `append_response()` (SEAL v1 support)
- **`/accepts` Negotiation**: New `negotiate_accepts()` method on `X402Client`
  - Sends merchant payment requirements to facilitator POST `/accepts`
  - Returns enriched requirements with feePayer, tokens, escrow config
  - Faremeter middleware compatibility
- **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_collector` and `protocol_fee_config` addresses (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
  - `Erc8004Client` class with identity, reputation, and feedback methods
  - `append_response()` method for agents to respond to feedback
  - `ProofOfPayment` model for reputation submission authorization
  - `build_erc8004_payment_requirements()` helper
- **Escrow & Refund Support**: Complete escrow payment flow
  - `EscrowClient` class with create, release, refund, dispute methods
  - `EscrowPayment`, `RefundRequest`, `Dispute` models
  - 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 `SuiPayloadContent` Pydantic model for Sui sponsored transactions
- Added `coinObjectId` as required field (CRITICAL for facilitator deserialization)
- Added `get_sui_payload()` method to `PaymentPayload`
- Updated `validate_sui_payload()` to require `coinObjectId`

### v0.5.5 (2025-12-30)

- Added AUSD (Agora USD) support for Sui mainnet
- Added `SUI_AUSD_COIN_TYPE_MAINNET` constant
- 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.SUI` for Sui Move VM chains
- Added `SUI_FEE_PAYER_MAINNET` and `SUI_FEE_PAYER_TESTNET` sponsor addresses
- Added CAIP-2 support for `sui:mainnet` and `sui: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`: 0x103040545AC5031A11E8C03dd11324C7333a13C7
  - `EVM_FACILITATOR_TESTNET`: 0x34033041a5944B8F10f8E4D8496Bfb84f1A293A8

### v0.5.1 (2025-12-26)

- Changed default Algorand mainnet network name from `algorand-mainnet` to `algorand`
- Aligns with facilitator v1.9.5+ which now uses `algorand` as the primary network identifier

### v0.5.0 (2025-12-26)

- **Facilitator Module**: Added `facilitator.py` with 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 `AlgorandPaymentPayload` dataclass with `paymentIndex` and `paymentGroup` fields
- 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_ID` constant
- Added `get_token_program_id()` and `is_token_2022()` helpers

### v0.4.0 (2025-12-26)

- **Algorand Support**: Added Algorand mainnet and testnet networks
- Added `ALGORAND` NetworkType
- Added `algorand` optional 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 `extra` field

### 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 `TokenType` literal type and `TokenConfig` dataclass
- Added token helper functions: `get_token_config()`, `get_supported_tokens()`, `is_token_supported()`, `get_networks_by_token()`
- Added `tokens` field to `NetworkConfig` for 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 `accepts` array 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 `MultiPaymentConfig` for multi-network recipient configuration
- Added `Payment402BuilderV2` for 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.

```bash
pip install 'uvd-x402-sdk[dx402]'
```

### Seller: one call

```python
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

```python
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

```python
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`](examples/dx402/seller_anchor.py) | the whole seller side in one call |
| [`examples/dx402/buyer_recover.py`](examples/dx402/buyer_recover.py) | recovery, tamper detection, and that another wallet cannot open it |
| [`examples/dx402/verified_anchor.py`](examples/dx402/verified_anchor.py) | reaching `verified: true` with `proof_of_payment` |
| [`examples/dx402/choose_storage.py`](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: permanent` is **irrevocable**.
- On Solana, `verified` is not reachable yet — the on-chain gate cannot read
  that payment, so `signed: true` is the honest maximum.

Full guide: [DX402.md](https://github.com/UltravioletaDAO/x402-rs/blob/main/docs/DX402.md)
