diff --git a/routstr/balance.py b/routstr/balance.py index 883c4673..5693d04b 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -74,35 +74,55 @@ 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( + 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.") + payment_data = topup_request.payment_data + payment_method = topup_request.payment_method or payment_method + elif cashu_token is not None: + payment_data = cashu_token + payment_method = payment_method or "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.") + + payment_data = payment_data.replace("\n", "").replace("\r", "").replace("\t", "") - 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) + amount_msats = await credit_balance(payment_data, key, session, 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="Invalid payment data format") + elif "could not detect" in error_msg.lower() or "unknown payment method" in error_msg.lower(): + raise HTTPException(status_code=400, detail=error_msg) else: - raise HTTPException(status_code=400, detail="Failed to redeem token") - except Exception: + raise HTTPException(status_code=400, detail="Failed to process payment") + except NotImplementedError as e: + raise HTTPException(status_code=501, detail=str(e)) + except Exception as e: + logger.error( + "Topup endpoint error", + extra={"error": str(e), "error_type": type(e).__name__}, + ) raise HTTPException(status_code=500, detail="Internal server error") return {"msats": amount_msats} @@ -165,35 +185,38 @@ async def refund_wallet_endpoint( elif remaining_balance <= 0: raise HTTPException(status_code=400, detail="No balance to refund") - # Perform refund operation first, before modifying balance + # Determine payment method from stored metadata + # For backward compatibility, if refund_mint_url exists, assume cashu + # TODO: Store payment_method in database for proper multi-method support + from .payment.registry import get_payment_method, initialize_payment_methods + + initialize_payment_methods() + payment_method_name = "cashu" + if key.refund_mint_url: + payment_method_name = "cashu" + + method = get_payment_method(payment_method_name) or get_payment_method("cashu") + if not method: + raise HTTPException(status_code=500, detail="Payment method not available") + + metadata = { + "mint_url": key.refund_mint_url, + "currency": key.refund_currency, + } + try: - if key.refund_address: - from .core.settings import settings as global_settings - - await send_to_lnurl( - remaining_balance, - key.refund_currency or "sat", - key.refund_mint_url or global_settings.primary_mint, - key.refund_address, - ) - result = {"recipient": key.refund_address} - else: - refund_currency = key.refund_currency or "sat" - token = await send_token( - remaining_balance, refund_currency, key.refund_mint_url - ) - result = {"token": token} - - if key.refund_currency == "sat": - result["sats"] = str(remaining_balance_msats // 1000) - else: - result["msats"] = str(remaining_balance_msats) - + result = await method.refund_payment( + remaining_balance_msats, + key.refund_currency or "sat", + key.refund_address, + metadata, + session, + ) + except NotImplementedError as e: + raise HTTPException(status_code=501, detail=str(e)) except HTTPException: - # Re-raise HTTP exceptions (like 400 for balance too small) raise except Exception as e: - # If refund fails, don't modify the database error_msg = str(e) if ( "mint" in error_msg.lower() @@ -201,7 +224,7 @@ async def refund_wallet_endpoint( or isinstance(e, Exception) and "ConnectError" in str(type(e)) ): - raise HTTPException(status_code=503, detail="Mint service unavailable") + raise HTTPException(status_code=503, detail="Payment service unavailable") else: raise HTTPException(status_code=500, detail="Refund failed") diff --git a/routstr/core/main.py b/routstr/core/main.py index 81212a01..0c4b0b60 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -83,6 +83,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: await _update_prices() await initialize_upstreams() + from ..payment.registry import initialize_payment_methods + + initialize_payment_methods() + logger.info("Payment methods initialized") + btc_price_task = asyncio.create_task(update_prices_periodically()) pricing_task = asyncio.create_task(update_sats_pricing()) if global_settings.models_refresh_interval_seconds > 0: diff --git a/routstr/payment/__init__.py b/routstr/payment/__init__.py index 55f5a854..b021302c 100644 --- a/routstr/payment/__init__.py +++ b/routstr/payment/__init__.py @@ -1,8 +1,23 @@ from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost +from .payment_methods import PaymentMethod, PaymentResult +from .registry import ( + detect_payment_method, + get_all_payment_methods, + get_payment_method, + initialize_payment_methods, + register_payment_method, +) __all__ = [ "CostData", "CostDataError", "MaxCostData", "calculate_cost", + "PaymentMethod", + "PaymentResult", + "detect_payment_method", + "get_all_payment_methods", + "get_payment_method", + "initialize_payment_methods", + "register_payment_method", ] diff --git a/routstr/payment/methods/__init__.py b/routstr/payment/methods/__init__.py new file mode 100644 index 00000000..75cd223a --- /dev/null +++ b/routstr/payment/methods/__init__.py @@ -0,0 +1,9 @@ +from .cashu import CashuPaymentMethod +from .lightning import BitcoinLightningPaymentMethod +from .usdt import USDTPaymentMethod + +__all__ = [ + "CashuPaymentMethod", + "BitcoinLightningPaymentMethod", + "USDTPaymentMethod", +] diff --git a/routstr/payment/methods/cashu.py b/routstr/payment/methods/cashu.py new file mode 100644 index 00000000..492b1c13 --- /dev/null +++ b/routstr/payment/methods/cashu.py @@ -0,0 +1,134 @@ +from cashu.wallet.helpers import deserialize_token_from_string +from sqlmodel import col, update + +from ...core.db import ApiKey, AsyncSession +from ...core.logging import get_logger +from ...wallet import recieve_token, send_to_lnurl, send_token +from ..payment_methods import PaymentMethod, PaymentResult + +logger = get_logger(__name__) + + +class CashuPaymentMethod(PaymentMethod): + @property + def name(self) -> str: + return "cashu" + + @property + def display_name(self) -> str: + return "Cashu eCash" + + async def validate_payment_data(self, payment_data: str) -> bool: + if not payment_data or len(payment_data) < 10: + return False + if "cashu" not in payment_data.lower(): + return False + try: + token_obj = deserialize_token_from_string(payment_data) + return token_obj is not None + except Exception: + return False + + async def process_payment( + self, payment_data: str, key: ApiKey, session: AsyncSession + ) -> PaymentResult: + 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( + "Cashu payment processed", + extra={"amount": amount, "unit": unit, "mint_url": mint_url}, + ) + + if unit == "sat": + amount_msats = amount * 1000 + else: + amount_msats = amount + + stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == key.hashed_key) + .values(balance=(ApiKey.balance) + amount_msats) + ) + await session.exec(stmt) + await session.commit() + await session.refresh(key) + + metadata = { + "mint_url": mint_url, + "currency": unit, + } + + return PaymentResult( + amount_msats=amount_msats, + currency=unit, + payment_method=self.name, + metadata=metadata, + ) + except Exception as e: + logger.error( + "Cashu payment processing failed", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + raise + + async def refund_payment( + self, + amount_msats: int, + currency: str, + refund_address: str | None, + metadata: dict[str, str | None], + session: AsyncSession, + ) -> dict[str, str]: + mint_url = metadata.get("mint_url") + if currency == "sat": + remaining_balance = amount_msats // 1000 + else: + remaining_balance = amount_msats + + if remaining_balance <= 0: + raise ValueError("No balance to refund") + + try: + if refund_address: + from ...core.settings import settings as global_settings + + await send_to_lnurl( + remaining_balance, + currency or "sat", + mint_url or global_settings.primary_mint, + refund_address, + ) + result = {"recipient": refund_address} + else: + token = await send_token(remaining_balance, currency, mint_url) + result = {"token": token} + + if currency == "sat": + result["sats"] = str(amount_msats // 1000) + else: + result["msats"] = str(amount_msats) + + return result + except Exception as e: + logger.error( + "Cashu refund failed", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + raise + + def extract_metadata(self, payment_data: str) -> dict[str, str | None]: + try: + token_obj = deserialize_token_from_string(payment_data) + return { + "mint_url": token_obj.mint, + "currency": token_obj.unit, + } + except Exception: + return { + "mint_url": None, + "currency": None, + } diff --git a/routstr/payment/methods/lightning.py b/routstr/payment/methods/lightning.py new file mode 100644 index 00000000..5dac0cea --- /dev/null +++ b/routstr/payment/methods/lightning.py @@ -0,0 +1,134 @@ +from ...core.db import ApiKey, AsyncSession +from ...core.logging import get_logger +from ..payment_methods import PaymentMethod, PaymentResult + +logger = get_logger(__name__) + + +class BitcoinLightningPaymentMethod(PaymentMethod): + @property + def name(self) -> str: + return "lightning" + + @property + def display_name(self) -> str: + return "Bitcoin Lightning Network" + + 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("lnbc") or payment_data.startswith("lntb") or payment_data.startswith("lnbcrt"): + return True + + if payment_data.startswith("lightning:") or payment_data.startswith("LIGHTNING:"): + invoice = payment_data.split(":", 1)[1] + return invoice.startswith("lnbc") or invoice.startswith("lntb") or invoice.startswith("lnbcrt") + + return False + + async def process_payment( + self, payment_data: str, key: ApiKey, session: AsyncSession + ) -> PaymentResult: + if not await self.validate_payment_data(payment_data): + raise ValueError("Invalid Lightning invoice format") + + payment_data = payment_data.strip() + if payment_data.startswith("lightning:") or payment_data.startswith("LIGHTNING:"): + payment_data = payment_data.split(":", 1)[1] + + logger.info( + "Processing Lightning payment", + extra={"invoice_preview": payment_data[:50]}, + ) + + # TODO: Full implementation requires: + # 1. Lightning node integration (e.g., LND, CLN, LDK, or similar) + # 2. Invoice verification and payment checking + # 3. Amount extraction from invoice (decode BOLT11 invoice) + # 4. Payment status polling or webhook handling + # 5. Atomic balance updates after payment confirmation + # 6. Error handling for expired/invalid invoices + # 7. Support for both mainnet and testnet + # + # Example libraries to consider: + # - pyln-client (for Core Lightning) + # - lndgrpc (for LND) + # - bolt11 (for invoice decoding) + # + # Example flow: + # 1. Decode invoice to get amount and expiry + # 2. Check if invoice is already paid (query node) + # 3. If not paid, wait for payment confirmation (poll or webhook) + # 4. Once confirmed, credit balance atomically + # 5. Store invoice hash in metadata to prevent double-spending + + raise NotImplementedError( + "Lightning payment processing not yet fully implemented. " + "Requires Lightning node integration and invoice verification." + ) + + async def refund_payment( + self, + amount_msats: int, + currency: str, + refund_address: str | None, + metadata: dict[str, str | None], + session: AsyncSession, + ) -> dict[str, str]: + if not refund_address: + raise ValueError("Refund address required for Lightning refunds") + + logger.info( + "Processing Lightning refund", + extra={"amount_msats": amount_msats, "refund_address": refund_address}, + ) + + # TODO: Full implementation requires: + # 1. Lightning node integration + # 2. Create outgoing invoice or use keysend + # 3. Send payment to refund_address + # 4. Handle payment failures and retries + # 5. Track payment status and confirmations + # + # Example flow: + # 1. Validate refund_address (Lightning address or node pubkey) + # 2. Create invoice or use keysend if supported + # 3. Send payment via Lightning node + # 4. Wait for payment confirmation + # 5. Return payment hash/preimage + + raise NotImplementedError( + "Lightning refund processing not yet fully implemented. " + "Requires Lightning node integration and payment sending capabilities." + ) + + def extract_metadata(self, payment_data: str) -> dict[str, str | None]: + payment_data = payment_data.strip() + if payment_data.startswith("lightning:") or payment_data.startswith("LIGHTNING:"): + payment_data = payment_data.split(":", 1)[1] + + # TODO: Decode BOLT11 invoice to extract: + # - Amount + # - Expiry timestamp + # - Payment hash + # - Description + # - Network (mainnet/testnet) + # + # Can use bolt11 library: + # from bolt11 import decode + # invoice = decode(payment_data) + # return { + # "amount_msats": invoice.amount_msat, + # "expiry": invoice.expiry, + # "payment_hash": invoice.payment_hash, + # "description": invoice.description, + # "network": invoice.network, + # } + + return { + "invoice": payment_data, + "currency": "sat", + } diff --git a/routstr/payment/methods/usdt.py b/routstr/payment/methods/usdt.py new file mode 100644 index 00000000..bf4d22cf --- /dev/null +++ b/routstr/payment/methods/usdt.py @@ -0,0 +1,145 @@ +import re + +from ...core.db import ApiKey, AsyncSession +from ...core.logging import get_logger +from ..payment_methods import PaymentResult, PaymentMethod + +logger = get_logger(__name__) + + +class USDTPaymentMethod(PaymentMethod): + @property + def name(self) -> str: + return "usdt" + + @property + def display_name(self) -> str: + return "USDT (Tether)" + + async def validate_payment_data(self, payment_data: str) -> bool: + if not payment_data: + return False + + payment_data = payment_data.strip() + + # USDT can be on multiple chains: Ethereum (ERC-20), Tron (TRC-20), etc. + # For now, we'll accept transaction hashes or payment addresses + # Ethereum address format: 0x followed by 40 hex characters + eth_address_pattern = r"^0x[a-fA-F0-9]{40}$" + # Tron address format: T followed by 33 alphanumeric characters + tron_address_pattern = r"^T[A-Za-z1-9]{33}$" + # Transaction hash: 64 hex characters (Ethereum) or 64 hex (Tron) + tx_hash_pattern = r"^[a-fA-F0-9]{64}$" + + return bool( + re.match(eth_address_pattern, payment_data) + or re.match(tron_address_pattern, payment_data) + or re.match(tx_hash_pattern, payment_data) + ) + + async def process_payment( + self, payment_data: str, key: ApiKey, session: AsyncSession + ) -> PaymentResult: + if not await self.validate_payment_data(payment_data): + raise ValueError("Invalid USDT payment data format") + + logger.info( + "Processing USDT payment", + extra={"payment_data_preview": payment_data[:50]}, + ) + + # TODO: Full implementation requires: + # 1. Blockchain RPC integration (Ethereum/Tron node) + # 2. Transaction verification and confirmation tracking + # 3. Amount extraction from transaction + # 4. USDT contract address verification + # 5. Support for multiple chains (Ethereum, Tron, Polygon, etc.) + # 6. Exchange rate conversion (USDT -> BTC/sats) + # 7. Atomic balance updates after confirmation + # 8. Handling of reorgs and chain splits + # + # Example libraries to consider: + # - web3.py (for Ethereum) + # - tronpy (for Tron) + # - Multiple confirmations required (typically 6+ for Ethereum, 20+ for Tron) + # + # Example flow: + # 1. Parse payment_data (address or transaction hash) + # 2. If address: monitor for incoming USDT transfers + # 3. If tx_hash: fetch and verify transaction + # 4. Verify USDT contract address matches + # 5. Extract amount from transaction + # 6. Wait for required confirmations + # 7. Convert USDT amount to BTC/sats using exchange rate + # 8. Credit balance atomically + # 9. Store transaction hash in metadata to prevent double-spending + + raise NotImplementedError( + "USDT payment processing not yet fully implemented. " + "Requires blockchain RPC integration, transaction verification, " + "and exchange rate conversion." + ) + + async def refund_payment( + self, + amount_msats: int, + currency: str, + refund_address: str | None, + metadata: dict[str, str | None], + session: AsyncSession, + ) -> dict[str, str]: + if not refund_address: + raise ValueError("Refund address required for USDT refunds") + + if not await self.validate_payment_data(refund_address): + raise ValueError("Invalid USDT refund address format") + + logger.info( + "Processing USDT refund", + extra={"amount_msats": amount_msats, "refund_address": refund_address}, + ) + + # TODO: Full implementation requires: + # 1. Blockchain RPC integration + # 2. Wallet/key management for sending USDT + # 3. Convert BTC/sats to USDT using exchange rate + # 4. Create and sign USDT transfer transaction + # 5. Broadcast transaction to network + # 6. Track transaction status and confirmations + # 7. Handle gas fees (for Ethereum) or energy (for Tron) + # 8. Support for multiple chains + # + # Example flow: + # 1. Convert amount_msats to USDT using current exchange rate + # 2. Determine chain from refund_address format + # 3. Load wallet/private key for that chain + # 4. Create USDT transfer transaction + # 5. Sign transaction + # 6. Broadcast to network + # 7. Return transaction hash + + raise NotImplementedError( + "USDT refund processing not yet fully implemented. " + "Requires blockchain RPC integration, wallet management, " + "and transaction broadcasting capabilities." + ) + + def extract_metadata(self, payment_data: str) -> dict[str, str | None]: + payment_data = payment_data.strip() + + # Determine chain based on address format + chain = None + if payment_data.startswith("0x"): + chain = "ethereum" + elif payment_data.startswith("T"): + chain = "tron" + elif len(payment_data) == 64: + # Transaction hash - would need to query blockchain to determine chain + chain = "unknown" + + return { + "address": payment_data if not payment_data.startswith("0x") or len(payment_data) == 42 else None, + "tx_hash": payment_data if len(payment_data) == 64 else None, + "chain": chain, + "currency": "usdt", + } diff --git a/routstr/payment/payment_methods.py b/routstr/payment/payment_methods.py new file mode 100644 index 00000000..6121eae9 --- /dev/null +++ b/routstr/payment/payment_methods.py @@ -0,0 +1,48 @@ +from abc import ABC, abstractmethod +from typing import TypedDict + +from ..core.db import ApiKey, AsyncSession + + +class PaymentResult(TypedDict): + amount_msats: int + currency: str + payment_method: str + metadata: dict[str, str | None] + + +class PaymentMethod(ABC): + @property + @abstractmethod + def name(self) -> str: + pass + + @property + @abstractmethod + def display_name(self) -> str: + pass + + @abstractmethod + async def validate_payment_data(self, payment_data: str) -> bool: + pass + + @abstractmethod + async def process_payment( + self, payment_data: str, key: ApiKey, session: AsyncSession + ) -> PaymentResult: + pass + + @abstractmethod + async def refund_payment( + self, + amount_msats: int, + currency: str, + refund_address: str | None, + metadata: dict[str, str | None], + session: AsyncSession, + ) -> dict[str, str]: + pass + + @abstractmethod + def extract_metadata(self, payment_data: str) -> dict[str, str | None]: + pass diff --git a/routstr/payment/registry.py b/routstr/payment/registry.py new file mode 100644 index 00000000..8a821238 --- /dev/null +++ b/routstr/payment/registry.py @@ -0,0 +1,35 @@ +from .methods.cashu import CashuPaymentMethod +from .methods.lightning import BitcoinLightningPaymentMethod +from .methods.usdt import USDTPaymentMethod +from .payment_methods import PaymentMethod + +_payment_methods: dict[str, PaymentMethod] = {} + + +def register_payment_method(method: PaymentMethod) -> None: + _payment_methods[method.name] = method + + +def get_payment_method(name: str) -> PaymentMethod | None: + return _payment_methods.get(name) + + +def get_all_payment_methods() -> dict[str, PaymentMethod]: + return _payment_methods.copy() + + +def detect_payment_method(payment_data: str) -> PaymentMethod | None: + for method in _payment_methods.values(): + try: + if method.validate_payment_data(payment_data): + return method + except Exception: + continue + return None + + +def initialize_payment_methods() -> None: + if not _payment_methods: + register_payment_method(CashuPaymentMethod()) + register_payment_method(BitcoinLightningPaymentMethod()) + register_payment_method(USDTPaymentMethod()) diff --git a/routstr/wallet.py b/routstr/wallet.py index 34569b85..cd58b21d 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -104,54 +104,44 @@ async def swap_to_primary_mint( async def credit_balance( - cashu_token: str, key: db.ApiKey, session: db.AsyncSession + payment_data: str, + key: db.ApiKey, + session: db.AsyncSession, + payment_method: str | None = None, ) -> int: + from .payment.registry import detect_payment_method, get_payment_method, initialize_payment_methods + + initialize_payment_methods() + logger.info( - "credit_balance: Starting token redemption", - extra={"token_preview": cashu_token[:50]}, + "credit_balance: Starting payment processing", + extra={"payment_data_preview": payment_data[:50], "payment_method": payment_method}, ) + method = None + if payment_method: + method = get_payment_method(payment_method) + if not method: + raise ValueError(f"Unknown payment method: {payment_method}") + else: + method = detect_payment_method(payment_data) + if not method: + raise ValueError("Could not detect payment method from payment data") + try: - amount, unit, mint_url = await recieve_token(cashu_token) + result = await method.process_payment(payment_data, key, session) logger.info( - "credit_balance: Token redeemed successfully", - extra={"amount": amount, "unit": unit, "mint_url": mint_url}, + "credit_balance: Payment processed successfully", + extra={ + "amount_msats": result["amount_msats"], + "currency": result["currency"], + "payment_method": result["payment_method"], + }, ) - - 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", + "credit_balance: Error during payment processing", extra={"error": str(e), "error_type": type(e).__name__}, ) raise