mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
1 Commits
bd1edcef26
...
cursor/abs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae050f84a1 |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
320
routstr/payment/methods.py
Normal file
320
routstr/payment/methods.py
Normal file
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user