Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
f5652d4e51 Refactor: Implement payment method abstraction
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-11-16 02:29:33 +00:00
10 changed files with 615 additions and 77 deletions

View File

@@ -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")

View File

@@ -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:

View File

@@ -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",
]

View File

@@ -0,0 +1,9 @@
from .cashu import CashuPaymentMethod
from .lightning import BitcoinLightningPaymentMethod
from .usdt import USDTPaymentMethod
__all__ = [
"CashuPaymentMethod",
"BitcoinLightningPaymentMethod",
"USDTPaymentMethod",
]

View File

@@ -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,
}

View File

@@ -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",
}

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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())

View File

@@ -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