From ae050f84a1bb08df51fb6b546610245720ef2a81 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Nov 2025 02:28:18 +0000 Subject: [PATCH] Refactor: Implement payment method abstraction Co-authored-by: db2002dominic --- routstr/balance.py | 70 ++++++-- routstr/payment/__init__.py | 16 ++ routstr/payment/methods.py | 320 ++++++++++++++++++++++++++++++++++++ routstr/wallet.py | 53 ++---- 4 files changed, 408 insertions(+), 51 deletions(-) create mode 100644 routstr/payment/methods.py diff --git a/routstr/balance.py b/routstr/balance.py index 883c4673..de9cc052 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -74,37 +74,77 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict: class TopupRequest(BaseModel): - cashu_token: str + payment_data: str + payment_method: str | None = None @router.post("/topup") async def topup_wallet_endpoint( cashu_token: str | None = None, + payment_data: str | None = None, + payment_method: 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]: + from .payment.methods import detect_payment_method, get_payment_method + + if topup_request is not None: + payment_data = topup_request.payment_data + payment_method = topup_request.payment_method + elif cashu_token is not None: + payment_data = cashu_token + payment_method = "cashu" + elif payment_data is None: + raise HTTPException( + status_code=400, + detail="Payment data is required. Provide 'payment_data' or 'cashu_token' (deprecated).", + ) + + if payment_data is None: + raise HTTPException(status_code=400, detail="Payment data is required.") + + if payment_method is None: + detected_method = detect_payment_method(payment_data) + if detected_method == "unknown": + raise HTTPException( + status_code=400, + detail="Could not detect payment method. Please specify 'payment_method' parameter.", + ) + payment_method = detected_method - 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) + result = await method.process_payment(payment_data, key, session) + return { + "msats": result["amount_msats"], + "currency": result["currency"], + "payment_method": result["payment_method"], + } 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 {payment_method} payment data format") + elif "not supported" in error_msg.lower() or "not implemented" in error_msg.lower(): + raise HTTPException( + status_code=501, + detail=f"Payment method '{payment_method}' is not fully implemented yet", + ) else: - raise HTTPException(status_code=400, detail="Failed to redeem token") - except Exception: + raise HTTPException(status_code=400, detail=f"Failed to process {payment_method} payment: {error_msg}") + except NotImplementedError as e: + raise HTTPException( + status_code=501, + detail=f"Payment method '{payment_method}' is not fully implemented: {str(e)}", + ) + except Exception as e: + logger.error( + "topup_wallet_endpoint: Unexpected error", + extra={"error": str(e), "error_type": type(e).__name__}, + ) 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..658ee7ac 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 ( + CashuPaymentMethod, + PaymentMethod, + PaymentResult, + BitcoinLightningPaymentMethod, + USDTetherPaymentMethod, + detect_payment_method, + get_payment_method, +) __all__ = [ "CostData", "CostDataError", "MaxCostData", "calculate_cost", + "PaymentMethod", + "PaymentResult", + "CashuPaymentMethod", + "BitcoinLightningPaymentMethod", + "USDTetherPaymentMethod", + "detect_payment_method", + "get_payment_method", ] diff --git a/routstr/payment/methods.py b/routstr/payment/methods.py new file mode 100644 index 00000000..af59d252 --- /dev/null +++ b/routstr/payment/methods.py @@ -0,0 +1,320 @@ +from abc import ABC, abstractmethod +from typing import TypedDict + +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..core.db import ApiKey +from ..core.logging import get_logger + +logger = get_logger(__name__) + + +class PaymentResult(TypedDict): + amount_msats: int + currency: str + mint_url: str | None + payment_method: str + + +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 + + @abstractmethod + async def validate_payment_data(self, payment_data: str) -> bool: + """Validate that the payment data is valid for this payment 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, currency, mint_url, and payment method + + Raises: + ValueError: If payment data is invalid or payment fails + Exception: For other payment processing errors + """ + pass + + @abstractmethod + async def get_supported_currencies(self) -> list[str]: + """Return list of currencies supported by this payment method.""" + pass + + +class CashuPaymentMethod(PaymentMethod): + """Payment method for Cashu eCash tokens.""" + + @property + def method_name(self) -> str: + return "cashu" + + async def validate_payment_data(self, payment_data: str) -> bool: + payment_data = payment_data.replace("\n", "").replace("\r", "").replace("\t", "") + if len(payment_data) < 10 or "cashu" not in payment_data.lower(): + return False + return True + + async def process_payment( + self, payment_data: str, api_key: ApiKey, session: AsyncSession + ) -> PaymentResult: + from ..wallet import recieve_token + + 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") + + try: + amount, unit, mint_url = await recieve_token(payment_data) + logger.info( + "CashuPaymentMethod: Token redeemed successfully", + extra={"amount": amount, "unit": unit, "mint_url": mint_url}, + ) + + if unit == "sat": + amount_msats = amount * 1000 + else: + amount_msats = amount + + from sqlmodel import col, update + + stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == api_key.hashed_key) + .values(balance=(ApiKey.balance) + amount_msats) + ) + await session.exec(stmt) + await session.commit() + await session.refresh(api_key) + + logger.info( + "CashuPaymentMethod: Balance updated successfully", + extra={"new_balance": api_key.balance}, + ) + + return PaymentResult( + amount_msats=amount_msats, + currency=unit, + mint_url=mint_url, + payment_method=self.method_name, + ) + except Exception as e: + logger.error( + "CashuPaymentMethod: Error during token redemption", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + raise + + async def get_supported_currencies(self) -> list[str]: + return ["sat", "msat"] + + +class BitcoinLightningPaymentMethod(PaymentMethod): + """Payment method for Bitcoin Lightning Network invoices. + + TODO: Full implementation requires: + 1. Lightning node integration (e.g., LND, CLN, or LDK) + 2. Invoice validation and payment verification + 3. Webhook/event handling for payment confirmation + 4. Support for both BOLT-11 invoices and LNURL-pay + 5. Fee calculation and handling + 6. Timeout handling for unpaid invoices + 7. Integration with existing wallet infrastructure for balance management + """ + + @property + def method_name(self) -> str: + return "lightning" + + async def validate_payment_data(self, payment_data: str) -> bool: + if not payment_data: + return False + invoice_lower = payment_data.lower().strip() + if invoice_lower.startswith("ln") or invoice_lower.startswith("lightning:"): + return True + if "@" in payment_data and "." in payment_data: + return True + return False + + async def process_payment( + self, payment_data: str, api_key: ApiKey, session: AsyncSession + ) -> PaymentResult: + """Pseudo-implementation of Lightning payment processing. + + TODO: Full implementation requires: + 1. Parse BOLT-11 invoice or LNURL-pay request + 2. Validate invoice amount and expiration + 3. Wait for payment confirmation via Lightning node + 4. Verify payment on-chain or via node + 5. Convert BTC amount to msats equivalent + 6. Credit balance to API key atomically + 7. Handle payment failures and timeouts + """ + logger.warning( + "BitcoinLightningPaymentMethod: Pseudo-implementation called", + extra={"payment_data_preview": payment_data[:50]}, + ) + + if not await self.validate_payment_data(payment_data): + raise ValueError("Invalid Lightning invoice format") + + from .lnurl import parse_lightning_invoice_amount + + try: + if payment_data.startswith("ln"): + amount_sats = parse_lightning_invoice_amount(payment_data, currency="sat") + amount_msats = amount_sats * 1000 + else: + raise ValueError("Only BOLT-11 invoices are currently supported in pseudo-implementation") + + logger.info( + "BitcoinLightningPaymentMethod: Invoice parsed (pseudo)", + extra={"amount_sats": amount_sats, "amount_msats": amount_msats}, + ) + + raise NotImplementedError( + "Lightning payment processing requires Lightning node integration. " + "See BitcoinLightningPaymentMethod.process_payment docstring for implementation details." + ) + + except Exception as e: + logger.error( + "BitcoinLightningPaymentMethod: Error processing payment", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + raise + + async def get_supported_currencies(self) -> list[str]: + return ["sat", "msat"] + + +class USDTetherPaymentMethod(PaymentMethod): + """Payment method for USDT (Tether) payments. + + TODO: Full implementation requires: + 1. Integration with USDT payment processor (e.g., Tether API, exchange API, or on-chain monitoring) + 2. Support for multiple networks (TRC-20 on Tron, ERC-20 on Ethereum, etc.) + 3. Address generation and monitoring for incoming payments + 4. Payment verification via blockchain explorer API or node + 5. Exchange rate conversion from USDT to msats + 6. Webhook handling for payment confirmations + 7. Multi-signature wallet support for security + 8. Gas fee handling for on-chain transactions + 9. Integration with existing balance management system + """ + + @property + def method_name(self) -> str: + return "usdt" + + async def validate_payment_data(self, payment_data: str) -> bool: + if not payment_data: + return False + payment_data = payment_data.strip() + if payment_data.startswith("0x") and len(payment_data) == 66: + return True + if payment_data.startswith("T") and len(payment_data) == 34: + return True + return False + + async def process_payment( + self, payment_data: str, api_key: ApiKey, session: AsyncSession + ) -> PaymentResult: + """Pseudo-implementation of USDT payment processing. + + TODO: Full implementation requires: + 1. Parse payment data (transaction hash, address, or payment ID) + 2. Query blockchain/API to verify payment + 3. Get USDT amount from transaction + 4. Convert USDT to msats using current exchange rate + 5. Verify sufficient confirmations (network-dependent) + 6. Credit balance to API key atomically + 7. Handle network-specific fees and gas costs + """ + logger.warning( + "USDTetherPaymentMethod: Pseudo-implementation called", + extra={"payment_data_preview": payment_data[:50]}, + ) + + if not await self.validate_payment_data(payment_data): + raise ValueError("Invalid USDT payment data format") + + logger.info( + "USDTetherPaymentMethod: Payment data validated (pseudo)", + extra={"payment_data": payment_data}, + ) + + raise NotImplementedError( + "USDT payment processing requires blockchain integration. " + "See USDTetherPaymentMethod.process_payment docstring for implementation details." + ) + + async def get_supported_currencies(self) -> list[str]: + return ["usdt"] + + +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 ("cashu", "lightning", "usdt") + + Returns: + PaymentMethod instance + + Raises: + ValueError: If payment method is not supported + """ + method_name_lower = method_name.lower() + + if method_name_lower == "cashu": + return CashuPaymentMethod() + elif method_name_lower == "lightning": + return BitcoinLightningPaymentMethod() + elif method_name_lower in ("usdt", "usdtether", "tether"): + return USDTetherPaymentMethod() + else: + raise ValueError(f"Unsupported payment method: {method_name}") + + +def detect_payment_method(payment_data: str) -> str: + """Auto-detect payment method from payment data. + + Args: + payment_data: Raw payment data string + + Returns: + Payment method name ("cashu", "lightning", "usdt", or "unknown") + """ + payment_data_lower = payment_data.lower().strip() + + if "cashu" in payment_data_lower: + return "cashu" + elif payment_data_lower.startswith("ln") or payment_data_lower.startswith("lightning:"): + return "lightning" + elif payment_data_lower.startswith("0x") or payment_data_lower.startswith("t"): + if len(payment_data) == 66 or len(payment_data) == 34: + return "usdt" + + return "unknown" diff --git a/routstr/wallet.py b/routstr/wallet.py index 34569b85..ca7694ca 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -106,49 +106,30 @@ async def swap_to_primary_mint( async def credit_balance( cashu_token: str, key: db.ApiKey, session: db.AsyncSession ) -> int: + """Credit balance using Cashu token (backward compatibility wrapper). + + This function is maintained for backward compatibility. New code should use + the payment method abstraction directly via PaymentMethod.process_payment(). + """ + from .payment.methods import CashuPaymentMethod + logger.info( - "credit_balance: Starting token redemption", + "credit_balance: Starting token redemption (legacy wrapper)", extra={"token_preview": cashu_token[:50]}, ) try: - amount, unit, mint_url = await recieve_token(cashu_token) + method = CashuPaymentMethod() + result = await method.process_payment(cashu_token, key, session) logger.info( - "credit_balance: Token redeemed successfully", - extra={"amount": amount, "unit": unit, "mint_url": mint_url}, + "credit_balance: Balance credited successfully", + extra={ + "amount_msats": result["amount_msats"], + "currency": result["currency"], + "mint_url": result["mint_url"], + }, ) - - if unit == "sat": - amount = amount * 1000 - logger.info( - "credit_balance: Converted to msat", extra={"amount_msat": amount} - ) - - logger.info( - "credit_balance: Updating balance", - extra={"old_balance": key.balance, "credit_amount": amount}, - ) - - # Use atomic SQL UPDATE to prevent race conditions during concurrent topups - stmt = ( - update(db.ApiKey) - .where(col(db.ApiKey.hashed_key) == key.hashed_key) - .values(balance=(db.ApiKey.balance) + amount) - ) - await session.exec(stmt) # type: ignore[call-overload] - await session.commit() - await session.refresh(key) - - logger.info( - "credit_balance: Balance updated successfully", - extra={"new_balance": key.balance}, - ) - - logger.info( - "Cashu token successfully redeemed and stored", - extra={"amount": amount, "unit": unit, "mint_url": mint_url}, - ) - return amount + return result["amount_msats"] except Exception as e: logger.error( "credit_balance: Error during token redemption",