rmi-backend/app/domains/billing/facilitators/eip7702.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

333 lines
12 KiB
Python

"""
EIP-7702 Universal EVM Facilitator
====================================
Support for ALL EVM blockchains, ALL tokens, and ALL native coins
using EIP-7702 delegated execution.
Unlike chain-specific facilitators, this one can verify payments on
any EVM chain with a JSON-RPC endpoint. It decodes ERC-20 Transfer
events from transaction receipts and verifies amounts on-chain.
Chains supported (expandable via RPC config):
- BNB Chain (BSC)
- Polygon PoS
- Avalanche C-Chain
- Fantom Opera
- Gnosis Chain
- Arbitrum One
- Optimism
- Base
- + any EVM chain with EIP-7702 delegation
Tokens supported: USDC, USDT, DAI, WBTC, WETH, native coins (ETH, POL, AVAX, etc.)
"""
import logging
from typing import Any
import aiohttp
from app.domains.billing.facilitators.base import (
Facilitator,
FacilitatorType,
NetworkSupport,
SettlementResult,
SettlementType,
VerificationResult,
)
from app.domains.billing.facilitators.config import FacilitatorConfig, get_config
logger = logging.getLogger("facilitator.eip7702")
# ERC-20 Transfer event signature
TRANSFER_TOPIC0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
class EIP7702Facilitator(Facilitator):
"""
Universal EVM facilitator using EIP-7702 delegated execution.
Supports any EVM chain with an RPC, any ERC-20 token, any native coin.
How it works:
1. Extract tx hash and chain from payment payload
2. Query the chain's RPC for transaction receipt
3. Decode ERC-20 Transfer events from receipt logs
4. Verify: correct token, correct recipient, correct amount
5. For native coins: verify value in tx receipt
"""
def __init__(self, config: FacilitatorConfig | None = None):
self._config = config or get_config()
# RPC URLs for all supported chains
self._rpc_urls = self._config.eip7702_default_rpcs.copy()
# Merge in any custom RPC URLs from env
custom = self._config.eip7702_rpc_urls
if custom:
self._rpc_urls.update(custom)
@property
def name(self) -> str:
return "eip7702"
@property
def facilitator_type(self) -> FacilitatorType:
return FacilitatorType.SELF_HOSTED
@property
def settlement_type(self) -> SettlementType:
return SettlementType.INSTANT
@property
def priority(self) -> int:
return 50 # Universal fallback - use after specialized facilitators
@property
def is_fee_free(self) -> bool:
return True # No facilitator fee - just RPC gas
@property
def supported_networks(self) -> NetworkSupport:
chains = list(self._rpc_urls.keys())
networks = [f"eip155:{cid}" for cid in self._chain_id_map().values()]
return NetworkSupport(
chains=chains,
networks=networks,
tokens=[
"USDC",
"USDT",
"DAI",
"WBTC",
"WETH",
"ETH",
"POL",
"AVAX",
"FTM",
"BNB",
"XDAI",
],
native_tokens=["ETH", "POL", "AVAX", "FTM", "BNB", "XDAI"],
)
@property
def description(self) -> str:
chains = list(self._rpc_urls.keys())
return f"EIP-7702 Universal EVM - {len(chains)} chains, all tokens, all native coins"
# ── Chain ID mapping ───────────────────────────────────────
def _chain_id_map(self) -> dict[str, int]:
"""Map chain key → chain ID."""
return {
"ethereum": 1,
"bsc": 56,
"polygon": 137,
"avalanche": 43114,
"fantom": 250,
"gnosis": 100,
"arbitrum": 42161,
"optimism": 10,
"base": 8453,
}
def _get_rpc_url(self, chain_key: str) -> str | None:
"""Get RPC URL for a chain key."""
return self._rpc_urls.get(chain_key.lower())
def _chain_id_for_key(self, chain_key: str) -> int | None:
return self._chain_id_map().get(chain_key.lower())
# ── Verification ───────────────────────────────────────────
async def verify(
self,
payload: dict[str, Any],
requirements: dict[str, Any] | None = None,
) -> VerificationResult:
"""Verify payment on any EVM chain via on-chain receipt inspection."""
# Determine chain
accepted = payload.get("accepted", {})
network = accepted.get("network", "eip155:1")
chain_key = network.split(":")[-1] if ":" in network else network
chain_key = chain_key.lower()
rpc_url = self._get_rpc_url(chain_key)
if not rpc_url:
return self._format_error(f"EIP-7702: No RPC configured for {chain_key}")
# Extract transaction hash
tx_hash = (
payload.get("txHash")
or payload.get("signature")
or payload.get("transactionHash")
or accepted.get("transaction")
)
if not tx_hash:
return self._format_error("EIP-7702: No transaction hash in payment payload")
# Get expected amount and token
amount_atoms = accepted.get("amount") or payload.get("amount")
expected_token = accepted.get("extra", {}).get("token") or accepted.get("token") or "USDC"
token_address = accepted.get("asset", "").lower()
is_native = expected_token.upper() in ["ETH", "POL", "AVAX", "FTM", "BNB", "XDAI"]
try:
receipt = await self._get_receipt(rpc_url, tx_hash)
if not receipt or isinstance(receipt, str):
return self._format_error(f"EIP-7702: TX {tx_hash[:16]}... not found on {chain_key}")
# Check transaction success
status = receipt.get("status", "0x0")
if int(status, 16) != 1:
return self._format_error(f"EIP-7702: TX {tx_hash[:16]}... failed (status=0)")
if is_native:
return self._verify_native(receipt, tx_hash, chain_key, amount_atoms, expected_token)
else:
return self._verify_erc20(receipt, tx_hash, chain_key, token_address, amount_atoms, expected_token)
except TimeoutError:
return self._format_error(f"EIP-7702: RPC timeout for {chain_key}")
except aiohttp.ClientError as e:
return self._format_error(f"EIP-7702: RPC error for {chain_key}: {e}")
except Exception as e:
logger.exception(f"EIP-7702 verify error: {e}")
return self._format_error(f"EIP-7702: {e}")
async def _get_receipt(self, rpc_url: str, tx_hash: str) -> dict | None:
"""Get transaction receipt via JSON-RPC."""
timeout = aiohttp.ClientTimeout(total=self._config.facilitator_verify_timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getTransactionReceipt",
"params": [tx_hash],
}
async with session.post(rpc_url, json=body) as resp:
data = await resp.json()
return data.get("result")
def _verify_native(
self,
receipt: dict,
tx_hash: str,
chain_key: str,
amount_atoms: str | None,
token: str,
) -> VerificationResult:
"""Verify native coin transfer (ETH, POL, AVAX, etc.)."""
# For native transfers, check the tx value
# We'd need eth_getTransactionByHash for the 'value' field
# This is a simplified check - full implementation queries the tx
payer = receipt.get("from", "")
to_addr = (receipt.get("to") or "").lower()
expected_to = self._config.evm_pay_to.lower()
if to_addr != expected_to:
return self._format_error(f"EIP-7702: Native transfer to {to_addr[:10]}..., expected {expected_to[:10]}...")
return self._format_success(
tx_hash=tx_hash,
payer=payer,
amount=amount_atoms or "0",
chain=chain_key,
token=token,
block_number=int(receipt.get("blockNumber", "0x0"), 16),
confirmations=1,
)
def _verify_erc20(
self,
receipt: dict,
tx_hash: str,
chain_key: str,
token_address: str,
amount_atoms: str | None,
token: str,
) -> VerificationResult:
"""Verify ERC-20 Transfer event in receipt logs."""
logs = receipt.get("logs", [])
expected_to = self._config.evm_pay_to.lower()
payer = receipt.get("from", "")
for log in logs:
topics = log.get("topics", [])
if len(topics) < 3:
continue
if topics[0].lower() != TRANSFER_TOPIC0:
continue
# Check token contract address
log_address = log.get("address", "").lower()
if token_address and log_address != token_address:
continue
# Decode 'to' address from topic2
to_address = "0x" + topics[2][-40:].lower()
if to_address != expected_to:
continue
# Decode amount from data field (uint256)
actual_amount = None
log_data = log.get("data", "0x")
try:
if log_data and log_data.startswith("0x") and len(log_data) >= 66:
actual_amount = str(int(log_data[:66], 16))
except (ValueError, TypeError):
pass
if amount_atoms and actual_amount and actual_amount != amount_atoms:
logger.warning(
f"EIP-7702: Amount mismatch on {chain_key} for {tx_hash[:16]}... "
f"expected {amount_atoms}, got {actual_amount}"
)
# Continue checking other logs - maybe multiple transfers
block_number = int(receipt.get("blockNumber", "0x0"), 16) if receipt.get("blockNumber") else None
return self._format_success(
tx_hash=tx_hash,
payer=payer,
amount=actual_amount or amount_atoms or "0",
chain=chain_key,
token=token,
block_number=block_number,
confirmations=1,
)
return self._format_error(f"EIP-7702: No ERC-20 Transfer to {expected_to[:10]}... found in {tx_hash[:16]}...")
# ── Health ─────────────────────────────────────────────────
async def health(self) -> bool:
"""Check if at least one RPC is reachable."""
for _chain_key, rpc_url in list(self._rpc_urls.items())[:3]:
try:
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session, session.post(
rpc_url,
json={"jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": []},
) as resp:
if resp.status == 200:
return True
except Exception:
continue
return False
# ── Settlement ─────────────────────────────────────────────
async def settle(self, payment_data: dict[str, Any]) -> SettlementResult:
"""EIP-7702 settlement via on-chain verification (already verified)."""
return SettlementResult(
settled=True,
settlement_id=payment_data.get("tx_hash"),
tx_hash=payment_data.get("tx_hash"),
chain=payment_data.get("chain"),
facilitator=self.name,
reason="Already settled on-chain (verified via receipt inspection)",
extra={"self_verified": True, "eip7702": True},
)