diff --git a/routstr/balance.py b/routstr/balance.py index 883c4673..2c9b90a9 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -10,6 +10,7 @@ from .auth import validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session from .core.logging import get_logger from .core.settings import settings +from .payment.methods import get_payment_method, list_payment_methods from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token router = APIRouter() @@ -74,37 +75,87 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict: class TopupRequest(BaseModel): - cashu_token: str + payment_data: str + payment_method: str = "cashu" + + +@router.get("/payment-methods") +async def get_payment_methods() -> dict[str, list[dict[str, str]]]: + """List all available payment methods for topup.""" + return {"methods": list_payment_methods()} @router.post("/topup") async def topup_wallet_endpoint( + payment_data: str | None = None, + payment_method: str | None = None, cashu_token: str | None = None, topup_request: TopupRequest | None = None, key: ApiKey = Depends(get_key_from_header), session: AsyncSession = Depends(get_session), -) -> dict[str, int]: - if topup_request is not None: - cashu_token = topup_request.cashu_token - if cashu_token is None: - raise HTTPException(status_code=400, detail="A cashu_token is required.") +) -> dict[str, int | str]: + # Backward compatibility: support old cashu_token parameter + if cashu_token is not None: + payment_data = cashu_token + payment_method = "cashu" + + # Support new TopupRequest format + if topup_request is not None: + payment_data = topup_request.payment_data + payment_method = topup_request.payment_method + + if payment_data is None: + raise HTTPException( + status_code=400, + detail="Payment data is required. Use 'payment_data' parameter or 'cashu_token' for backward compatibility.", + ) + + if payment_method is None: + payment_method = "cashu" - cashu_token = cashu_token.replace("\n", "").replace("\r", "").replace("\t", "") - if len(cashu_token) < 10 or "cashu" not in cashu_token: - raise HTTPException(status_code=400, detail="Invalid token format") try: - amount_msats = await credit_balance(cashu_token, key, session) + method = get_payment_method(payment_method) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + if not await method.validate_payment_data(payment_data): + raise HTTPException( + status_code=400, + detail=f"Invalid payment data format for {method.display_name}", + ) + + try: + result = await method.process_payment(payment_data, key, session) + response: dict[str, int | str] = { + "msats": result["amount_msats"], + "payment_method": result["payment_method"], + } + if result["payment_id"]: + response["payment_id"] = result["payment_id"] + return response + except NotImplementedError as e: + raise HTTPException( + status_code=501, + detail=f"{method.display_name} payment method is not yet fully implemented. {str(e)}", + ) except ValueError as e: error_msg = str(e) if "already spent" in error_msg.lower(): - raise HTTPException(status_code=400, detail="Token already spent") + raise HTTPException(status_code=400, detail="Payment already processed") elif "invalid" in error_msg.lower() or "decode" in error_msg.lower(): - raise HTTPException(status_code=400, detail="Invalid token format") + raise HTTPException(status_code=400, detail=f"Invalid {method.display_name} format") else: - raise HTTPException(status_code=400, detail="Failed to redeem token") - except Exception: + raise HTTPException(status_code=400, detail=f"Payment processing failed: {error_msg}") + except Exception as e: + logger.error( + "Topup payment processing error", + extra={ + "error": str(e), + "error_type": type(e).__name__, + "payment_method": payment_method, + }, + ) raise HTTPException(status_code=500, detail="Internal server error") - return {"msats": amount_msats} _REFUND_CACHE_TTL_SECONDS: int = settings.refund_cache_ttl_seconds diff --git a/routstr/payment/__init__.py b/routstr/payment/__init__.py index 55f5a854..1e9f1fc3 100644 --- a/routstr/payment/__init__.py +++ b/routstr/payment/__init__.py @@ -1,8 +1,24 @@ from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost +from .methods import ( + BitcoinLightningPaymentMethod, + CashuPaymentMethod, + PaymentMethod, + PaymentResult, + USDTPaymentMethod, + get_payment_method, + list_payment_methods, +) __all__ = [ "CostData", "CostDataError", "MaxCostData", "calculate_cost", + "PaymentMethod", + "PaymentResult", + "CashuPaymentMethod", + "BitcoinLightningPaymentMethod", + "USDTPaymentMethod", + "get_payment_method", + "list_payment_methods", ] diff --git a/routstr/payment/methods.py b/routstr/payment/methods.py new file mode 100644 index 00000000..48113a58 --- /dev/null +++ b/routstr/payment/methods.py @@ -0,0 +1,298 @@ +from abc import ABC, abstractmethod +from typing import TypedDict + +from ..core.db import ApiKey, AsyncSession + + +class PaymentResult(TypedDict): + amount_msats: int + payment_method: str + payment_id: str | None + + +class PaymentMethod(ABC): + """Abstract base class for payment methods used for temporary balance topups.""" + + @property + @abstractmethod + def method_name(self) -> str: + """Return the name identifier for this payment method.""" + pass + + @property + @abstractmethod + def display_name(self) -> str: + """Return a human-readable display name for this payment method.""" + pass + + @abstractmethod + async def validate_payment_data(self, payment_data: str) -> bool: + """Validate that the payment data is in the correct format for this method. + + Args: + payment_data: The raw payment data (e.g., cashu token, lightning invoice, etc.) + + Returns: + True if the payment data is valid, False otherwise + """ + pass + + @abstractmethod + async def process_payment( + self, payment_data: str, api_key: ApiKey, session: AsyncSession + ) -> PaymentResult: + """Process a payment and credit the balance to the API key. + + Args: + payment_data: The raw payment data for this payment method + api_key: The API key to credit the balance to + session: Database session + + Returns: + PaymentResult containing amount credited, method name, and optional payment ID + + Raises: + ValueError: If payment data is invalid or payment fails + Exception: For other payment processing errors + """ + pass + + @abstractmethod + async def get_refund_info( + self, api_key: ApiKey + ) -> dict[str, str | None] | None: + """Get refund information for this payment method if applicable. + + Args: + api_key: The API key to get refund info for + + Returns: + Dictionary with refund information or None if refunds not supported + """ + pass + + +class CashuPaymentMethod(PaymentMethod): + """Payment method for Cashu eCash tokens.""" + + @property + def method_name(self) -> str: + return "cashu" + + @property + def display_name(self) -> str: + return "Cashu eCash" + + async def validate_payment_data(self, payment_data: str) -> bool: + payment_data = payment_data.replace("\n", "").replace("\r", "").replace("\t", "") + return len(payment_data) >= 10 and "cashu" in payment_data.lower() + + async def process_payment( + self, payment_data: str, api_key: ApiKey, session: AsyncSession + ) -> PaymentResult: + from ..wallet import credit_balance + + payment_data = payment_data.replace("\n", "").replace("\r", "").replace("\t", "") + if not await self.validate_payment_data(payment_data): + raise ValueError("Invalid Cashu token format") + + amount_msats = await credit_balance(payment_data, api_key, session) + return PaymentResult( + amount_msats=amount_msats, + payment_method=self.method_name, + payment_id=None, + ) + + async def get_refund_info( + self, api_key: ApiKey + ) -> dict[str, str | None] | None: + if api_key.refund_address: + return { + "refund_address": api_key.refund_address, + "refund_mint_url": api_key.refund_mint_url, + "refund_currency": api_key.refund_currency, + } + return None + + +class BitcoinLightningPaymentMethod(PaymentMethod): + """Payment method for Bitcoin Lightning Network invoices.""" + + @property + def method_name(self) -> str: + return "lightning" + + @property + def display_name(self) -> str: + return "Bitcoin Lightning" + + async def validate_payment_data(self, payment_data: str) -> bool: + invoice = payment_data.strip().lower() + return invoice.startswith("ln") and len(invoice) > 20 + + async def process_payment( + self, payment_data: str, api_key: ApiKey, session: AsyncSession + ) -> PaymentResult: + # TODO: Full implementation requires: + # 1. Lightning node integration (LND, CLN, or similar) + # 2. Invoice verification and payment status checking + # 3. Webhook/callback system for payment confirmation + # 4. Database schema to track pending payments + # 5. Background task to poll payment status + # 6. Integration with payment processor API (e.g., BTCPay Server, Strike, etc.) + # + # Example flow: + # 1. Parse invoice to get amount_msats + # 2. Verify invoice hasn't been paid + # 3. Store pending payment in database with invoice_hash + # 4. Wait for payment confirmation (via webhook or polling) + # 5. Once confirmed, credit balance to api_key + # 6. Return PaymentResult with payment_id = invoice_hash + + invoice = payment_data.strip() + if not await self.validate_payment_data(invoice): + raise ValueError("Invalid Lightning invoice format") + + # Pseudo-implementation: Parse invoice amount + # In real implementation, use a Lightning library to decode invoice + try: + from ..payment.lnurl import parse_lightning_invoice_amount + + amount_msats = parse_lightning_invoice_amount(invoice, currency="msat") + except Exception: + raise ValueError("Failed to parse Lightning invoice amount") + + # Pseudo-implementation: In real system, this would: + # 1. Check if invoice is already paid + # 2. If not paid, store as pending payment + # 3. Return pending status + # 4. Credit balance once payment confirmed via webhook/polling + + raise NotImplementedError( + "Lightning payment processing requires Lightning node integration. " + "See method docstring for implementation requirements." + ) + + async def get_refund_info( + self, api_key: ApiKey + ) -> dict[str, str | None] | None: + if api_key.refund_address and api_key.refund_address.startswith("ln"): + return { + "refund_address": api_key.refund_address, + "refund_currency": api_key.refund_currency or "sat", + } + return None + + +class USDTPaymentMethod(PaymentMethod): + """Payment method for USDT (Tether) on various blockchains.""" + + @property + def method_name(self) -> str: + return "usdt" + + @property + def display_name(self) -> str: + return "USDT (Tether)" + + async def validate_payment_data(self, payment_data: str) -> bool: + # TODO: Full implementation requires validation based on blockchain: + # - Ethereum: 0x... address format (42 chars) + # - Tron: T... address format (34 chars) + # - Polygon: 0x... address format + # - BSC: 0x... address format + # - Solana: Base58 encoded address (32-44 chars) + # + # For now, basic validation + payment_data = payment_data.strip() + return ( + len(payment_data) >= 26 + and (payment_data.startswith("0x") or payment_data.startswith("T")) + ) + + async def process_payment( + self, payment_data: str, api_key: ApiKey, session: AsyncSession + ) -> PaymentResult: + # TODO: Full implementation requires: + # 1. Blockchain RPC integration (Ethereum, Tron, Polygon, BSC, Solana) + # 2. USDT contract address for each chain + # 3. Transaction monitoring system (webhook or polling) + # 4. Exchange rate API to convert USDT to BTC/sats + # 5. Database schema to track pending transactions + # 6. Background task to monitor transaction confirmations + # 7. Multi-signature or escrow system for security + # + # Example flow: + # 1. User provides transaction hash or payment address + # 2. Monitor blockchain for USDT transfer to our address + # 3. Verify transaction amount and confirmations + # 4. Convert USDT amount to BTC/sats using exchange rate + # 5. Credit balance to api_key + # 6. Return PaymentResult with payment_id = transaction_hash + + payment_data = payment_data.strip() + if not await self.validate_payment_data(payment_data): + raise ValueError("Invalid USDT payment data format") + + # Pseudo-implementation: In real system, this would: + # 1. Parse transaction hash or payment address + # 2. Query blockchain for transaction details + # 3. Verify USDT transfer amount + # 4. Get BTC/USDT exchange rate + # 5. Convert to msats and credit balance + + raise NotImplementedError( + "USDT payment processing requires blockchain integration. " + "See method docstring for implementation requirements." + ) + + async def get_refund_info( + self, api_key: ApiKey + ) -> dict[str, str | None] | None: + # USDT refunds would require blockchain address + # This would need to be stored separately or in refund_address + # with a prefix/format to identify it as USDT + return None + + +def get_payment_method(method_name: str) -> PaymentMethod: + """Factory function to get a payment method by name. + + Args: + method_name: Name of the payment method (e.g., 'cashu', 'lightning', 'usdt') + + Returns: + PaymentMethod instance + + Raises: + ValueError: If payment method is not found + """ + methods: dict[str, PaymentMethod] = { + "cashu": CashuPaymentMethod(), + "lightning": BitcoinLightningPaymentMethod(), + "usdt": USDTPaymentMethod(), + } + + if method_name.lower() not in methods: + raise ValueError( + f"Unknown payment method: {method_name}. " + f"Available methods: {', '.join(methods.keys())}" + ) + + return methods[method_name.lower()] + + +def list_payment_methods() -> list[dict[str, str]]: + """List all available payment methods. + + Returns: + List of dictionaries with 'method' and 'display_name' keys + """ + methods = [ + CashuPaymentMethod(), + BitcoinLightningPaymentMethod(), + USDTPaymentMethod(), + ] + return [ + {"method": m.method_name, "display_name": m.display_name} for m in methods + ]