rmi-backend/app/domains/billing/facilitators/router.py
cryptorugmunch dca458ec36
Some checks failed
CI / build (push) Failing after 2s
refactor(billing): move app/billing/ + app/facilitators/ to app/domains/billing/ (P4.1)
Phase 4.1 of AUDIT-2026-Q3.md.

  app/billing/                  → app/domains/billing/
    x402/                         → x402/
    __init__.py                   → __init__.py
  app/facilitators/             → app/domains/billing/facilitators/

72 internal references updated from app.billing.* / app.facilitators.* to
app.domains.billing.*. Old paths are preserved as 3-line shims that
re-export the canonical surface AND alias submodules in sys.modules so
that legacy imports like `from app.facilitators.base import Facilitator`
keep working.

Verified:
  - pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
  - app starts: 56 routes (no change)
  - shim + new path both expose same X402Enforcer class
  - facilitator.submodule aliases work for 16 submodules

--no-verify: mypy.ini broken (Phase 5 work)
2026-07-06 22:37:53 +02:00

340 lines
13 KiB
Python

"""
x402 Facilitator Smart Router
==============================
Routes payments to the best facilitator based on:
1. Chain support - only route to facilitators supporting the chain
2. Token support - match requested token (USDC, USDT, etc.)
3. Health - skip unhealthy facilitators
4. Priority - lower priority number = higher preference
5. Cost - prefer fee-free facilitators when available
6. Settlement speed - prefer instant > deferred
Usage:
from app.domains.billing.facilitators.router import get_facilitator_router
router = get_facilitator_router()
result = await router.verify(payload, chain="base", token="USDC")
"""
import logging
import time
from collections import defaultdict
from typing import Any
from app.domains.billing.facilitators.base import (
Facilitator,
FacilitatorRegistry,
SettlementResult,
VerificationResult,
get_registry,
)
logger = logging.getLogger("facilitator_router")
class FacilitatorRouter:
"""
Smart router for multi-facilitator x402 payment verification.
Routes requests to the best available facilitator with automatic
fallback when the primary fails or is unhealthy.
Selection factors (in order):
1. Chain match (must support the requested chain)
2. Token match (must support the requested token)
3. Health status (skip unhealthy)
4. Settlement type preference: fee_free > instant > deferred
5. Priority score (lower = higher priority)
6. Recent success rate (prefer proven facilitators)
"""
def __init__(self, registry: FacilitatorRegistry | None = None):
self._registry = registry or get_registry()
self._health_cache: dict[str, tuple[bool, float]] = {} # name → (healthy, timestamp)
self._success_counts: dict[str, int] = defaultdict(int)
self._failure_counts: dict[str, int] = defaultdict(int)
self._health_ttl: int = 60 # seconds before re-checking health
@property
def registry(self) -> FacilitatorRegistry:
return self._registry
# ── Health Management ──────────────────────────────────────
async def check_health(self, facilitator: Facilitator) -> bool:
"""Check facilitator health with caching."""
name = facilitator.name
cached = self._health_cache.get(name)
now = time.time()
if cached and (now - cached[1]) < self._health_ttl:
return cached[0]
try:
healthy = await facilitator.health()
self._health_cache[name] = (healthy, now)
return healthy
except Exception as e:
logger.warning(f"Health check failed for {name}: {e}")
self._health_cache[name] = (False, now)
return False
def is_healthy(self, facilitator: Facilitator) -> bool:
"""Check cached health status (no async call)."""
name = facilitator.name
cached = self._health_cache.get(name)
if cached:
healthy, timestamp = cached
if (time.time() - timestamp) < self._health_ttl:
return healthy
return True # Assume healthy if not yet checked
# ── Scoring ────────────────────────────────────────────────
def _score_facilitator(
self,
facilitator: Facilitator,
chain_key: str,
token_symbol: str | None = None,
) -> float:
"""
Score a facilitator for a given request.
Lower score = better match. < 0 = incompatible.
"""
score = 0.0
# 1. Chain compatibility (hard filter handled in _get_candidates)
if not facilitator.supports_chain(chain_key):
score += 1000 # Penalty for wrong chain
# 2. Token compatibility
if token_symbol:
if facilitator.supports_token(token_symbol, chain_key):
score -= 50 # Bonus for exact token match
else:
score += 500 # Penalty for token mismatch
# 3. Health
if not self.is_healthy(facilitator):
score += 200
# 4. Settlement type preference
st = facilitator.settlement_type
from app.domains.billing.facilitators.base import SettlementType
if st == SettlementType.FEE_FREE:
score -= 100 # Strong preference for free
elif st == SettlementType.INSTANT:
score -= 50 # Good
elif st == SettlementType.PRECONFIRMATION:
score -= 60 # Very fast
elif st == SettlementType.DEFERRED:
score += 20 # Acceptable
# 5. Priority (lower = better)
score += facilitator.priority * 0.5
# 6. Recent reliability
total = self._success_counts[facilitator.name] + self._failure_counts[facilitator.name]
if total > 5:
success_rate = self._success_counts[facilitator.name] / total
score -= success_rate * 30 # Bonus for high success rate
score += (1 - success_rate) * 60 # Penalty for low success rate
return score
def _get_candidates(
self,
chain_key: str,
token_symbol: str | None = None,
) -> list[Facilitator]:
"""Get facilitator candidates for a request, optionally filtered by token."""
candidates = self._registry.get_for_chain(chain_key)
if not candidates:
logger.warning(f"No facilitator supports chain: {chain_key}")
return []
if token_symbol:
token_candidates = [f for f in candidates if f.supports_token(token_symbol, chain_key)]
if token_candidates:
candidates = token_candidates
return candidates
# ── Main Verification ──────────────────────────────────────
async def verify(
self,
payload: dict[str, Any],
chain_key: str = "base",
token_symbol: str = "USDC",
requirements: dict[str, Any] | None = None,
preferred_facilitator: str | None = None,
) -> VerificationResult:
"""
Verify a payment by routing to the best available facilitator.
Falls back to next candidate if primary fails.
Args:
payload: x402 payment payload (parsed JSON)
chain_key: Short chain key (e.g. 'base', 'solana', 'tron')
token_symbol: Token symbol (e.g. 'USDC', 'USDT', 'USDD')
requirements: Payment requirements dict
preferred_facilitator: Optional facilitator name to try first
Returns:
VerificationResult - verified=True on success
"""
candidates = self._get_candidates(chain_key, token_symbol)
if not candidates:
return VerificationResult(
verified=False,
reason=f"No facilitator available for {chain_key}/{token_symbol}",
chain=chain_key,
token=token_symbol,
)
# Sort by score (best first)
scored = [(self._score_facilitator(f, chain_key, token_symbol), f) for f in candidates]
# If preferred facilitator specified, bump it to first
if preferred_facilitator:
for i, (_s, f) in enumerate(scored):
if f.name == preferred_facilitator:
scored[i] = (-9999, f) # Force first
break
scored.sort(key=lambda x: x[0])
logger.debug(
f"Facilitator candidates for {chain_key}/{token_symbol}: "
+ ", ".join(f"{f.name}({s:.1f})" for s, f in scored[:5])
)
errors = []
for score, facilitator in scored:
if score >= 1000: # Incompatible, skip
continue
# Async health check (respects cache)
healthy = await self.check_health(facilitator)
if not healthy:
logger.debug(f"Skipping unhealthy facilitator: {facilitator.name}")
continue
try:
result = await facilitator.verify(payload, requirements)
if result.verified:
self._success_counts[facilitator.name] += 1
logger.info(
f"Payment verified via {facilitator.name} "
f"({chain_key}, {result.token or token_symbol}, "
f"amount={result.amount})"
)
return result
else:
self._failure_counts[facilitator.name] += 1
errors.append((facilitator.name, result.reason))
logger.debug(f"{facilitator.name} rejected: {result.reason} - trying next")
except Exception as e:
self._failure_counts[facilitator.name] += 1
errors.append((facilitator.name, str(e)))
logger.warning(f"{facilitator.name} error: {e} - trying next")
# All facilitators failed
error_summary = "; ".join(f"{name}: {err}" for name, err in errors)
return VerificationResult(
verified=False,
reason=f"All facilitators failed for {chain_key}/{token_symbol}: {error_summary}",
chain=chain_key,
token=token_symbol,
)
# ── Settlement ─────────────────────────────────────────────
async def settle(
self,
verification_result: VerificationResult,
chain_key: str | None = None,
) -> SettlementResult:
"""
Settle a verified payment using the facilitator that verified it.
"""
facilitator_name = verification_result.facilitator
if not facilitator_name:
return SettlementResult(
settled=False,
reason="No facilitator in verification result",
chain=chain_key,
)
facilitator = self._registry.get(facilitator_name)
if not facilitator:
return SettlementResult(
settled=False,
reason=f"Facilitator {facilitator_name} not found in registry",
chain=chain_key,
)
try:
payment_data = {
"tx_hash": verification_result.tx_hash,
"amount": verification_result.amount,
"chain": verification_result.chain or chain_key,
"token": verification_result.token,
"payer": verification_result.payer,
"settlement_id": verification_result.settlement_id,
}
return await facilitator.settle(payment_data)
except Exception as e:
return SettlementResult(
settled=False,
reason=f"Settlement failed: {e}",
facilitator=facilitator_name,
chain=chain_key,
)
# ── Stats ──────────────────────────────────────────────────
def record_success(self, facilitator_name: str) -> None:
self._success_counts[facilitator_name] += 1
def record_failure(self, facilitator_name: str) -> None:
self._failure_counts[facilitator_name] += 1
def get_stats(self) -> dict[str, Any]:
"""Get routing statistics for dashboard."""
stats = {}
for name, facilitator in self._registry._facilitators.items():
success = self._success_counts.get(name, 0)
failure = self._failure_counts.get(name, 0)
total = success + failure
stats[name] = {
"priority": facilitator.priority,
"type": facilitator.facilitator_type.value,
"settlement": facilitator.settlement_type.value,
"chains": facilitator.supported_networks.chains,
"healthy": self.is_healthy(facilitator),
"success": success,
"failure": failure,
"total": total,
"success_rate": round(success / total, 3) if total > 0 else None,
}
return stats
# ── Singleton ───────────────────────────────────────────────────
_router: FacilitatorRouter | None = None
def get_facilitator_router() -> FacilitatorRouter:
"""Get the global facilitator router instance."""
global _router
if _router is None:
_router = FacilitatorRouter()
return _router