rmi-backend/app/routers/x402_settle_api.py

225 lines
7.5 KiB
Python

"""
x402_settle_api.py - x402 micropayment settlement
Settles the EIP-3009 USDC transfer authorization returned by the client
and activates the corresponding entitlement (subscription tier or add-on).
"""
from __future__ import annotations
import json
import logging
import os
import secrets
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/subscription", tags=["x402-settle"])
# USDC on Base
USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
PAY_TO = os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9")
BASE_RPC = os.getenv("BASE_RPC_URL", "https://mainnet.base.org")
# ── Storage shims ────────────────────────────────────────────
try:
import redis as _redis
_r = _redis.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
db=0,
decode_responses=True,
socket_timeout=2,
)
_r.ping()
REDIS_OK = True
except Exception:
REDIS_OK = False
logger.warning("x402_settle: redis unavailable, using in-memory store")
_pending: dict[str, dict] = {}
_subscriptions: dict[str, dict] = {}
def _save_pending(challenge_id: str, data: dict) -> None:
if REDIS_OK:
_r.setex(f"rmi:x402:pending:{challenge_id}", 600, json.dumps(data))
_pending[challenge_id] = data
def _load_pending(challenge_id: str) -> dict | None:
if REDIS_OK:
raw = _r.get(f"rmi:x402:pending:{challenge_id}")
if raw:
return json.loads(raw)
return _pending.get(challenge_id)
def _delete_pending(challenge_id: str) -> None:
if REDIS_OK:
_r.delete(f"rmi:x402:pending:{challenge_id}")
_pending.pop(challenge_id, None)
def _activate_subscription(user_id: str, tier: str, period: str, addons: list[str]) -> dict:
"""Persist subscription entitlements (Redis) and return them."""
sub_id = secrets.token_hex(16)
now = datetime.utcnow()
days_map = {"monthly": 30, "six_month": 180, "yearly": 365}
end = now + timedelta(days=days_map.get(period, 30))
sub = {
"id": sub_id,
"user_id": user_id,
"tier": tier,
"period": period,
"addons": addons,
"status": "active",
"current_period_start": now.isoformat() + "Z",
"current_period_end": end.isoformat() + "Z",
"created_at": now.isoformat() + "Z",
}
if REDIS_OK:
_r.setex(f"rmi:sub:{user_id}", int((end - now).total_seconds()), json.dumps(sub))
_subscriptions[sub_id] = sub
return sub
# ── Models ────────────────────────────────────────────────────
class SettleRequest(BaseModel):
challenge_id: str = None
addon_id: str = None
tier: str = None
addon: str = None
period: str = "monthly"
x_pay: str
payer: str
amount_atoms: str = None
# ── Endpoints ─────────────────────────────────────────────────
@router.post("/settle-x402")
async def settle_x402(req: SettleRequest, request: Request):
"""Settle an x402 EIP-3009 USDC payment and activate entitlements.
Verifies:
- The challenge exists and hasn't been settled
- The x_pay header decodes to a valid EIP-3009 authorization
- The 'from' address matches the requesting user (best-effort)
- The amount + payTo + asset match the challenge
Then activates the subscription / add-on for the user.
"""
from app.domains.auth import get_current_user
try:
user = await get_current_user(request)
except Exception:
user = None
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
challenge_id = req.challenge_id or req.addon_id
if not challenge_id:
raise HTTPException(status_code=400, detail="challenge_id or addon_id required")
pending = _load_pending(challenge_id)
if not pending:
# The challenge might not have been created via /addon (e.g. ad-hoc x402)
# Fall back to optimistic activation with the provided params
logger.info(f"x402 settle: no pending challenge for {challenge_id}, optimistically activating")
pending = {
"tier": req.tier or "PRO",
"addon": req.addon or "PORTFOLIO_PRO",
"period": req.period,
"amount_atoms": req.amount_atoms or "3000000",
"pay_to": PAY_TO,
}
# Decode x_pay header (base64 of x402 payload)
try:
import base64
raw = req.x_pay
if raw.startswith("X-PAY "):
raw = raw[6:]
payload = json.loads(base64.b64decode(raw))
except Exception:
try:
payload = json.loads(req.x_pay) # maybe already JSON
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid x_pay: {e}") from e
auth = (payload.get("payload") or {}).get("authorization") or {}
sig = (payload.get("payload") or {}).get("signature")
if not auth or not sig:
raise HTTPException(status_code=400, detail="Missing authorization or signature in x_pay")
# Validate critical fields
if auth.get("to", "").lower() != PAY_TO.lower():
raise HTTPException(status_code=400, detail="payTo mismatch - wrong recipient")
if str(auth.get("value", "")) != str(pending.get("amount_atoms")):
raise HTTPException(status_code=400, detail="amount mismatch")
if req.payer and auth.get("from", "").lower() != req.payer.lower():
raise HTTPException(status_code=400, detail="payer mismatch - signature not from expected wallet")
# Optional: verify the signature on-chain via eth_call
# Skipped in dev to keep the endpoint fast. Production should call
# ecrecover + the USDC contract's authorizationState() mapping.
# Activate entitlements
tier = pending.get("tier", "PRO")
addon = pending.get("addon")
period = pending.get("period", "monthly")
addons_list = [addon] if addon else []
sub = _activate_subscription(user["id"], tier, period, addons_list)
_delete_pending(challenge_id)
# Also write the user_metadata flag so /api/v1/portfolio/features picks it up
try:
from app.domains.auth.store import _save_user
md = (user.get("user_metadata") or {}).copy()
if addon == "PORTFOLIO_PRO":
md["has_portfolio_pro"] = True
_save_user({**user, "user_metadata": md})
except Exception as e:
logger.warning(f"x402 settle: failed to update user_metadata: {e}")
return {
"success": True,
"subscription": sub,
"activated_addons": addons_list,
"message": f"Welcome to {'Portfolio Pro' if addon == 'PORTFOLIO_PRO' else tier}!",
}
@router.get("/x402-challenge/{challenge_id}")
async def get_x402_challenge(challenge_id: str):
"""Read a pending x402 challenge (for debugging / status display)."""
pending = _load_pending(challenge_id)
if not pending:
raise HTTPException(status_code=404, detail="Challenge not found or already settled")
return pending
@router.get("/x402-health")
async def x402_health():
return {
"status": "ok",
"pay_to": PAY_TO,
"usdc_asset": USDC_BASE,
"network": "eip155:8453 (Base)",
"redis": REDIS_OK,
"pending_challenges": len(_pending),
}