From 92d7b7e86fb192b3bd0c397f92f3e8e2c50c8a4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Nov 2025 19:38:22 +0000 Subject: [PATCH] Refactor: Extract payment methods into separate classes Co-authored-by: db2002dominic --- routstr/auth.py | 165 ++++------------------------ routstr/payment/__init__.py | 12 ++ routstr/payment/methods.py | 211 ++++++++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 141 deletions(-) create mode 100644 routstr/payment/methods.py diff --git a/routstr/auth.py b/routstr/auth.py index b1e16987..2999b7ed 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -1,9 +1,7 @@ -import hashlib import math from typing import Optional from fastapi import HTTPException -from sqlalchemy.exc import IntegrityError from sqlmodel import col, update from .core import get_logger @@ -15,7 +13,7 @@ from .payment.cost_caculation import ( MaxCostData, calculate_cost, ) -from .wallet import credit_balance, deserialize_token_from_string +from .payment.methods import TEMPORARY_BALANCE_PAYMENT_METHODS logger = get_logger(__name__) @@ -104,151 +102,36 @@ async def validate_bearer_key( extra={"key_preview": bearer_key[:10] + "..."}, ) - if bearer_key.startswith("cashu"): + for method in TEMPORARY_BALANCE_PAYMENT_METHODS: + if not method.supports(bearer_key): + continue + logger.debug( - "Processing Cashu token", + "Delegating bearer key handling to payment method", extra={ - "token_preview": bearer_key[:20] + "...", - "token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key, + "method": method.method_name, + "key_preview": bearer_key[:20] + "..." + if len(bearer_key) > 20 + else bearer_key, }, ) try: - hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest() - token_obj = deserialize_token_from_string(bearer_key) - logger.debug( - "Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."} + return await method.ensure_api_key( + bearer_key, + session, + refund_address, + key_expiry_time, ) - - if existing_key := await session.get(ApiKey, hashed_key): - logger.info( - "Existing Cashu token found", - extra={ - "key_hash": existing_key.hashed_key[:8] + "...", - "balance": existing_key.balance, - "total_requests": existing_key.total_requests, - }, - ) - - if key_expiry_time is not None: - existing_key.key_expiry_time = key_expiry_time - logger.debug( - "Updated key expiry time for existing Cashu key", - extra={ - "key_hash": existing_key.hashed_key[:8] + "...", - "expiry_time": key_expiry_time, - }, - ) - - if refund_address is not None: - existing_key.refund_address = refund_address - logger.debug( - "Updated refund address for existing Cashu key", - extra={ - "key_hash": existing_key.hashed_key[:8] + "...", - "refund_address_preview": refund_address[:20] + "..." - if len(refund_address) > 20 - else refund_address, - }, - ) - - return existing_key - - logger.info( - "Creating new Cashu token entry", - extra={ - "hash_preview": hashed_key[:16] + "...", - "has_refund_address": bool(refund_address), - "has_expiry_time": bool(key_expiry_time), - }, - ) - if token_obj.mint in settings.cashu_mints: - refund_currency = token_obj.unit - refund_mint_url = token_obj.mint - else: - refund_currency = "sat" - refund_mint_url = settings.primary_mint - - new_key = ApiKey( - hashed_key=hashed_key, - balance=0, - refund_address=refund_address, - key_expiry_time=key_expiry_time, - refund_currency=refund_currency, - refund_mint_url=refund_mint_url, - ) - session.add(new_key) - - try: - await session.flush() - except IntegrityError: - await session.rollback() - logger.info( - "Concurrent key creation detected, fetching existing key", - extra={"key_hash": hashed_key[:8] + "..."}, - ) - existing_key = await session.get(ApiKey, hashed_key) - if not existing_key: - raise Exception("Failed to fetch existing key after IntegrityError") - - if key_expiry_time is not None: - existing_key.key_expiry_time = key_expiry_time - if refund_address is not None: - existing_key.refund_address = refund_address - - return existing_key - - logger.debug( - "New key created, starting token redemption", - extra={"key_hash": hashed_key[:8] + "..."}, - ) - - logger.info( - "AUTH: About to call credit_balance", - extra={"token_preview": bearer_key[:50]}, - ) - try: - msats = await credit_balance(bearer_key, new_key, session) - logger.info( - "AUTH: credit_balance returned successfully", extra={"msats": msats} - ) - except Exception as credit_error: - logger.error( - "AUTH: credit_balance failed", - extra={ - "error": str(credit_error), - "error_type": type(credit_error).__name__, - }, - ) - raise credit_error - - if msats <= 0: - logger.error( - "Token redemption returned zero or negative amount", - extra={"msats": msats, "key_hash": hashed_key[:8] + "..."}, - ) - raise Exception("Token redemption failed") - - await session.refresh(new_key) - await session.commit() - - logger.info( - "New Cashu token successfully redeemed and stored", - extra={ - "key_hash": hashed_key[:8] + "...", - "redeemed_msats": msats, - "final_balance": new_key.balance, - }, - ) - - return new_key - except Exception as e: + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 - we need the original error preserved logger.error( - "Cashu token redemption failed", + "Payment method failed to process bearer key", extra={ - "error": str(e), - "error_type": type(e).__name__, - "token_preview": bearer_key[:20] + "..." + "method": method.method_name, + "error": str(exc), + "key_preview": bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key, }, @@ -257,12 +140,12 @@ async def validate_bearer_key( status_code=401, detail={ "error": { - "message": f"Invalid or expired Cashu key: {str(e)}", + "message": "Unable to process payment for provided key", "type": "invalid_request_error", "code": "invalid_api_key", } }, - ) + ) from exc logger.error( "Invalid API key format", diff --git a/routstr/payment/__init__.py b/routstr/payment/__init__.py index 55f5a854..ae109761 100644 --- a/routstr/payment/__init__.py +++ b/routstr/payment/__init__.py @@ -1,8 +1,20 @@ from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost +from .methods import ( + CashuPaymentMethod, + BitcoinLightningPaymentMethod, + TemporaryBalancePaymentMethod, + TEMPORARY_BALANCE_PAYMENT_METHODS, + UsdtTetherPaymentMethod, +) __all__ = [ "CostData", "CostDataError", "MaxCostData", "calculate_cost", + "TemporaryBalancePaymentMethod", + "CashuPaymentMethod", + "BitcoinLightningPaymentMethod", + "UsdtTetherPaymentMethod", + "TEMPORARY_BALANCE_PAYMENT_METHODS", ] diff --git a/routstr/payment/methods.py b/routstr/payment/methods.py new file mode 100644 index 00000000..bca44ce9 --- /dev/null +++ b/routstr/payment/methods.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import hashlib +from abc import ABC, abstractmethod + +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError + +from ..core import get_logger +from ..core.db import ApiKey, AsyncSession +from ..core.settings import settings +from ..wallet import credit_balance, deserialize_token_from_string + +logger = get_logger(__name__) + + +class TemporaryBalancePaymentMethod(ABC): + """Interface for temporary balance payment methods.""" + + method_name: str = "abstract" + + @abstractmethod + def supports(self, bearer_key: str) -> bool: + """Return True when this method can process the provided bearer key.""" + + @abstractmethod + async def ensure_api_key( + self, + bearer_key: str, + session: AsyncSession, + refund_address: str | None, + key_expiry_time: int | None, + ) -> ApiKey: + """Return an ApiKey by creating or updating state for the bearer key.""" + + +class CashuPaymentMethod(TemporaryBalancePaymentMethod): + """Existing Cashu-backed temporary balance workflow.""" + + method_name = "cashu" + + def supports(self, bearer_key: str) -> bool: + return bearer_key.startswith("cashu") + + async def ensure_api_key( + self, + bearer_key: str, + session: AsyncSession, + refund_address: str | None, + key_expiry_time: int | None, + ) -> ApiKey: + try: + hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest() + token = deserialize_token_from_string(bearer_key) + except Exception as exc: # noqa: BLE001 - we need the original error + logger.error( + "Failed to decode Cashu token", + extra={ + "error": str(exc), + "token_preview": bearer_key[:20] + "..." + if len(bearer_key) > 20 + else bearer_key, + }, + ) + raise HTTPException( + status_code=401, + detail={ + "error": { + "message": f"Invalid or expired Cashu key: {str(exc)}", + "type": "invalid_request_error", + "code": "invalid_api_key", + } + }, + ) from exc + + if existing_key := await session.get(ApiKey, hashed_key): + if key_expiry_time is not None: + existing_key.key_expiry_time = key_expiry_time + if refund_address is not None: + existing_key.refund_address = refund_address + return existing_key + + if token.mint in settings.cashu_mints: + refund_currency = token.unit + refund_mint_url = token.mint + else: + refund_currency = "sat" + refund_mint_url = settings.primary_mint + + new_key = ApiKey( + hashed_key=hashed_key, + balance=0, + refund_address=refund_address, + key_expiry_time=key_expiry_time, + refund_currency=refund_currency, + refund_mint_url=refund_mint_url, + ) + session.add(new_key) + + try: + await session.flush() + except IntegrityError: + await session.rollback() + existing_key = await session.get(ApiKey, hashed_key) + if not existing_key: + raise Exception("Failed to fetch key after IntegrityError") # noqa: TRY002 + if key_expiry_time is not None: + existing_key.key_expiry_time = key_expiry_time + if refund_address is not None: + existing_key.refund_address = refund_address + return existing_key + + msats = await credit_balance(bearer_key, new_key, session) + if msats <= 0: + raise HTTPException( + status_code=401, + detail={ + "error": { + "message": "Token redemption failed", + "type": "invalid_request_error", + "code": "invalid_api_key", + } + }, + ) + + await session.refresh(new_key) + await session.commit() + return new_key + + +class BitcoinLightningPaymentMethod(TemporaryBalancePaymentMethod): + """Placeholder Lightning payment method.""" + + method_name = "bitcoin-lightning" + + def supports(self, bearer_key: str) -> bool: + return bearer_key.lower().startswith("lnbc") or bearer_key.lower().startswith( + "lightning:" + ) + + async def ensure_api_key( + self, + bearer_key: str, + session: AsyncSession, + refund_address: str | None, + key_expiry_time: int | None, + ) -> ApiKey: + logger.info( + "Lightning payment placeholder invoked", + extra={"token_preview": bearer_key[:20] + "..."}, + ) + # Full implementation would need to 1) decode the invoice, 2) verify settlement + # with a Lightning node or service, and 3) credit the ApiKey once payment clears. + raise HTTPException( + status_code=501, + detail={ + "error": { + "message": "Bitcoin Lightning payments are not yet supported", + "type": "payment_method_not_available", + "code": "lightning_not_implemented", + } + }, + ) + + +class UsdtTetherPaymentMethod(TemporaryBalancePaymentMethod): + """Placeholder USDT (on-chain / custodial) payment method.""" + + method_name = "usdtether" + + def supports(self, bearer_key: str) -> bool: + return bearer_key.lower().startswith("usdt:") + + async def ensure_api_key( + self, + bearer_key: str, + session: AsyncSession, + refund_address: str | None, + key_expiry_time: int | None, + ) -> ApiKey: + logger.info( + "USDT payment placeholder invoked", + extra={"token_preview": bearer_key[:20] + "..."}, + ) + # A production-ready version would need to integrate with a stablecoin custody + # provider, track confirmations, perform AML checks, and finally mint balances. + raise HTTPException( + status_code=501, + detail={ + "error": { + "message": "USDT payments are not yet supported", + "type": "payment_method_not_available", + "code": "usdt_not_implemented", + } + }, + ) + + +TEMPORARY_BALANCE_PAYMENT_METHODS: list[TemporaryBalancePaymentMethod] = [ + CashuPaymentMethod(), + BitcoinLightningPaymentMethod(), + UsdtTetherPaymentMethod(), +] + +__all__ = [ + "TemporaryBalancePaymentMethod", + "CashuPaymentMethod", + "BitcoinLightningPaymentMethod", + "UsdtTetherPaymentMethod", + "TEMPORARY_BALANCE_PAYMENT_METHODS", +]