- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
225 lines
7.5 KiB
Python
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.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.auth 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),
|
|
}
|