"""x402Client base classes and internal types.
Contains shared logic for client implementations.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Generator
from dataclasses import dataclass, field
from typing import Any, Literal
from typing_extensions import Self
from .interfaces import SchemeNetworkClient, SchemeNetworkClientV1
from .schemas import (
AbortResult,
Network,
NoMatchingRequirementsError,
PaymentCreatedContext,
PaymentCreationContext,
PaymentCreationFailureContext,
PaymentPayload,
PaymentPayloadV1,
PaymentRequired,
PaymentRequiredV1,
PaymentRequirements,
PaymentRequirementsV1,
RecoveredPayloadResult,
ResourceInfo,
SchemeNotFoundError,
find_schemes_by_network,
)
# ============================================================================
# Type Aliases
# ============================================================================
# V2 types
Requirements = PaymentRequirements
RequirementsView = PaymentRequirements | PaymentRequirementsV1
# Policy: filter requirements list (e.g., prefer_network, max_amount)
PaymentPolicy = Callable[[int, list[RequirementsView]], list[RequirementsView]]
# Selector: choose final requirement from filtered list
PaymentRequirementsSelector = Callable[[int, list[RequirementsView]], RequirementsView]
# ============================================================================
# Configuration Types
# ============================================================================
@dataclass
class SchemeRegistration:
"""Configuration for registering a payment scheme with a specific network."""
network: Network
client: SchemeNetworkClient | SchemeNetworkClientV1
x402_version: int = 2
@dataclass
class x402ClientConfig:
"""Configuration options for creating x402Client from config."""
schemes: list[SchemeRegistration]
policies: list[PaymentPolicy] | None = None
payment_requirements_selector: PaymentRequirementsSelector | None = field(default=None)
# Hook types - support both sync and async (for async class auto-detection)
BeforePaymentCreationHook = Callable[
[PaymentCreationContext], Awaitable[AbortResult | None] | AbortResult | None
]
AfterPaymentCreationHook = Callable[[PaymentCreatedContext], Awaitable[None] | None]
OnPaymentCreationFailureHook = Callable[
[PaymentCreationFailureContext],
Awaitable[RecoveredPayloadResult | None] | RecoveredPayloadResult | None,
]
# Sync-only hook types (for sync class)
SyncBeforePaymentCreationHook = Callable[[PaymentCreationContext], AbortResult | None]
SyncAfterPaymentCreationHook = Callable[[PaymentCreatedContext], None]
SyncOnPaymentCreationFailureHook = Callable[
[PaymentCreationFailureContext], RecoveredPayloadResult | None
]
# Hook command type for generator-based implementation
HookPhase = Literal["before", "after", "failure"]
HookCommand = tuple[HookPhase, Any, Any] # (phase, hook, context)
# ============================================================================
# Default Implementations
# ============================================================================
def default_payment_selector(
version: int,
requirements: list[RequirementsView],
) -> RequirementsView:
"""Default selector: return first requirement."""
return requirements[0]
# ============================================================================
# Built-in Policies
# ============================================================================
def prefer_network(network: Network) -> PaymentPolicy:
"""Create policy that prefers a specific network."""
def policy(version: int, reqs: list[RequirementsView]) -> list[RequirementsView]:
preferred = [r for r in reqs if r.network == network]
others = [r for r in reqs if r.network != network]
return preferred + others
return policy
def prefer_scheme(scheme: str) -> PaymentPolicy:
"""Create policy that prefers a specific scheme."""
def policy(version: int, reqs: list[RequirementsView]) -> list[RequirementsView]:
preferred = [r for r in reqs if r.scheme == scheme]
others = [r for r in reqs if r.scheme != scheme]
return preferred + others
return policy
def max_amount(max_value: int) -> PaymentPolicy:
"""Create policy that filters by maximum amount."""
def policy(version: int, reqs: list[RequirementsView]) -> list[RequirementsView]:
return [r for r in reqs if int(r.get_amount()) <= max_value]
return policy
# ============================================================================
# Base Client Class (Shared Logic)
# ============================================================================
class x402ClientBase:
"""Base class with shared logic for x402 clients.
Contains registration, policies, selection, and generator-based
payment creation logic.
"""
def __init__(
self,
payment_requirements_selector: PaymentRequirementsSelector | None = None,
) -> None:
"""Initialize base client."""
self._selector = payment_requirements_selector or default_payment_selector
self._schemes: dict[Network, dict[str, SchemeNetworkClient]] = {}
self._schemes_v1: dict[Network, dict[str, SchemeNetworkClientV1]] = {}
self._policies: list[PaymentPolicy] = []
# Hooks (typed in subclasses)
self._before_payment_creation_hooks: list[Any] = []
self._after_payment_creation_hooks: list[Any] = []
self._on_payment_creation_failure_hooks: list[Any] = []
# ========================================================================
# Registration
# ========================================================================
def register(self, network: Network, client: SchemeNetworkClient) -> Self:
"""Register a V2 scheme client for a network."""
if network not in self._schemes:
self._schemes[network] = {}
self._schemes[network][client.scheme] = client
return self
def register_v1(self, network: Network, client: SchemeNetworkClientV1) -> Self:
"""Register a V1 scheme client for a network."""
if network not in self._schemes_v1:
self._schemes_v1[network] = {}
self._schemes_v1[network][client.scheme] = client
return self
def register_policy(self, policy: PaymentPolicy) -> Self:
"""Add a requirement filter policy."""
self._policies.append(policy)
return self
# ========================================================================
# Selection (Shared)
# ========================================================================
def _select_requirements_v2(
self,
requirements: list[PaymentRequirements],
) -> PaymentRequirements:
"""Select V2 requirements using policies and selector."""
# Filter to supported schemes
supported = []
for req in requirements:
schemes = find_schemes_by_network(self._schemes, req.network)
if schemes and req.scheme in schemes:
supported.append(req)
if not supported:
raise NoMatchingRequirementsError("No payment requirements match registered schemes")
# Apply policies
filtered: list[RequirementsView] = list(supported)
for policy in self._policies:
filtered = policy(2, filtered)
if not filtered:
raise NoMatchingRequirementsError("All requirements filtered out by policies")
# Select final
return self._selector(2, filtered) # type: ignore[return-value]
def _select_requirements_v1(
self,
requirements: list[PaymentRequirementsV1],
) -> PaymentRequirementsV1:
"""Select V1 requirements using policies and selector."""
# Filter to supported schemes
supported = []
for req in requirements:
schemes = find_schemes_by_network(self._schemes_v1, req.network)
if schemes and req.scheme in schemes:
supported.append(req)
if not supported:
raise NoMatchingRequirementsError("No payment requirements match registered schemes")
# Apply policies
filtered: list[RequirementsView] = list(supported)
for policy in self._policies:
filtered = policy(1, filtered)
if not filtered:
raise NoMatchingRequirementsError("All requirements filtered out by policies")
# Select final
return self._selector(1, filtered) # type: ignore[return-value]
# ========================================================================
# Introspection
# ========================================================================
def get_registered_schemes(
self,
) -> dict[int, list[dict[str, str]]]:
"""Get list of registered schemes for debugging."""
result: dict[int, list[dict[str, str]]] = {1: [], 2: []}
for network, schemes in self._schemes.items():
for scheme in schemes:
result[2].append({"network": network, "scheme": scheme})
for network, schemes in self._schemes_v1.items():
for scheme in schemes:
result[1].append({"network": network, "scheme": scheme})
return result
# ========================================================================
# Core Logic Generators (shared between async/sync)
# ========================================================================
def _create_payment_payload_v2_core(
self,
payment_required: PaymentRequired,
resource: ResourceInfo | None,
extensions: dict[str, Any] | None,
) -> Generator[HookCommand, Any, PaymentPayload]:
"""Core V2 payment creation logic as generator.
Yields (phase, hook, context) tuples for hook execution.
"""
# 1. Select requirements
selected = self._select_requirements_v2(payment_required.accepts)
# 2. Build context
context = PaymentCreationContext(
payment_required=payment_required,
selected_requirements=selected,
)
# 3. Execute before hooks
for hook in self._before_payment_creation_hooks:
result = yield ("before", hook, context)
if isinstance(result, AbortResult):
from .schemas import PaymentAbortedError
raise PaymentAbortedError(result.reason)
try:
# 4. Find scheme client
schemes = find_schemes_by_network(self._schemes, selected.network)
if schemes is None or selected.scheme not in schemes:
raise SchemeNotFoundError(selected.scheme, selected.network)
client = schemes[selected.scheme]
# 5. Create inner payload
inner_payload = client.create_payment_payload(selected)
# 6. Wrap into full PaymentPayload
payload = PaymentPayload(
x402_version=2,
payload=inner_payload,
accepted=selected,
resource=resource or payment_required.resource,
extensions=extensions or payment_required.extensions,
)
# 7. Execute after hooks
result_context = PaymentCreatedContext(
payment_required=payment_required,
selected_requirements=selected,
payment_payload=payload,
)
for hook in self._after_payment_creation_hooks:
yield ("after", hook, result_context)
return payload
except Exception as e:
# Execute failure hooks
failure_context = PaymentCreationFailureContext(
payment_required=payment_required,
selected_requirements=selected,
error=e,
)
for hook in self._on_payment_creation_failure_hooks:
result = yield ("failure", hook, failure_context)
if isinstance(result, RecoveredPayloadResult):
return result.payload # type: ignore[return-value]
raise
def _create_payment_payload_v1_core(
self,
payment_required: PaymentRequiredV1,
) -> Generator[HookCommand, Any, PaymentPayloadV1]:
"""Core V1 payment creation logic as generator.
Yields (phase, hook, context) tuples for hook execution.
"""
# 1. Select requirements
selected = self._select_requirements_v1(payment_required.accepts)
# 2. Build context
context = PaymentCreationContext(
payment_required=payment_required,
selected_requirements=selected,
)
# 3. Execute before hooks
for hook in self._before_payment_creation_hooks:
result = yield ("before", hook, context)
if isinstance(result, AbortResult):
from .schemas import PaymentAbortedError
raise PaymentAbortedError(result.reason)
try:
# 4. Find scheme client
schemes = find_schemes_by_network(self._schemes_v1, selected.network)
if schemes is None or selected.scheme not in schemes:
raise SchemeNotFoundError(selected.scheme, selected.network)
client = schemes[selected.scheme]
# 5. Create inner payload
inner_payload = client.create_payment_payload(selected)
# 6. Wrap into full PaymentPayloadV1
payload = PaymentPayloadV1(
x402_version=1,
scheme=selected.scheme,
network=selected.network,
payload=inner_payload,
)
# 7. Execute after hooks
result_context = PaymentCreatedContext(
payment_required=payment_required,
selected_requirements=selected,
payment_payload=payload,
)
for hook in self._after_payment_creation_hooks:
yield ("after", hook, result_context)
return payload
except Exception as e:
# Execute failure hooks
failure_context = PaymentCreationFailureContext(
payment_required=payment_required,
selected_requirements=selected,
error=e,
)
for hook in self._on_payment_creation_failure_hooks:
result = yield ("failure", hook, failure_context)
if isinstance(result, RecoveredPayloadResult):
return result.payload # type: ignore[return-value]
raise