Compare commits

...
Author SHA1 Message Date
Cursor Agentanddb2002dominic 7559546561 Refactor: Introduce payment method abstraction
This commit refactors the payment processing logic by introducing an abstract PaymentMethod class. This allows for easier integration of new payment methods like Lightning and USDT in the future. The existing Cashu token logic is adapted to fit this new structure.

Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-11-16 01:22:19 +00:00
5 changed files with 549 additions and 53 deletions
+42 -30
View File
@@ -15,6 +15,7 @@ from .payment.cost_caculation import (
MaxCostData,
calculate_cost,
)
from .payment.method_implementations import get_payment_method
from .wallet import credit_balance, deserialize_token_from_string
logger = get_logger(__name__)
@@ -104,25 +105,26 @@ async def validate_bearer_key(
extra={"key_preview": bearer_key[:10] + "..."},
)
if bearer_key.startswith("cashu"):
payment_method = get_payment_method(bearer_key)
if payment_method:
logger.debug(
"Processing Cashu token",
f"Processing {payment_method.method_type} payment",
extra={
"token_preview": bearer_key[:20] + "...",
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
"payment_preview": bearer_key[:20] + "...",
"method_type": payment_method.method_type,
},
)
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
token_obj = deserialize_token_from_string(bearer_key)
metadata = await payment_method.validate_payment_data(bearer_key, session)
logger.debug(
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
"Generated payment hash", extra={"hash_preview": hashed_key[:16] + "..."}
)
if existing_key := await session.get(ApiKey, hashed_key):
logger.info(
"Existing Cashu token found",
f"Existing {payment_method.method_type} payment found",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"balance": existing_key.balance,
@@ -133,7 +135,7 @@ async def validate_bearer_key(
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
logger.debug(
"Updated key expiry time for existing Cashu key",
"Updated key expiry time for existing payment",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"expiry_time": key_expiry_time,
@@ -143,7 +145,7 @@ async def validate_bearer_key(
if refund_address is not None:
existing_key.refund_address = refund_address
logger.debug(
"Updated refund address for existing Cashu key",
"Updated refund address for existing payment",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"refund_address_preview": refund_address[:20] + "..."
@@ -155,19 +157,25 @@ async def validate_bearer_key(
return existing_key
logger.info(
"Creating new Cashu token entry",
f"Creating new {payment_method.method_type} payment entry",
extra={
"hash_preview": hashed_key[:16] + "...",
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
"method_type": payment_method.method_type,
},
)
if token_obj.mint in settings.cashu_mints:
refund_currency = token_obj.unit
refund_mint_url = token_obj.mint
else:
refund_currency = "sat"
refund_mint_url = settings.primary_mint
refund_currency = metadata.get("currency") or "sat"
refund_mint_url = metadata.get("mint_url") or settings.primary_mint
if payment_method.method_type == "cashu":
if metadata.get("mint_url") in settings.cashu_mints:
refund_currency = metadata.get("currency") or "sat"
refund_mint_url = metadata.get("mint_url") or settings.primary_mint
else:
refund_currency = "sat"
refund_mint_url = settings.primary_mint
new_key = ApiKey(
hashed_key=hashed_key,
@@ -199,22 +207,24 @@ async def validate_bearer_key(
return existing_key
logger.debug(
"New key created, starting token redemption",
extra={"key_hash": hashed_key[:8] + "..."},
"New key created, starting payment processing",
extra={"key_hash": hashed_key[:8] + "...", "method_type": payment_method.method_type},
)
logger.info(
"AUTH: About to call credit_balance",
extra={"token_preview": bearer_key[:50]},
f"AUTH: About to call {payment_method.method_type} credit_balance",
extra={"payment_preview": bearer_key[:50]},
)
try:
msats = await credit_balance(bearer_key, new_key, session)
payment_result = await payment_method.credit_balance(bearer_key, new_key, session)
msats = payment_result["amount_msats"]
logger.info(
"AUTH: credit_balance returned successfully", extra={"msats": msats}
f"AUTH: {payment_method.method_type} credit_balance returned successfully",
extra={"msats": msats},
)
except Exception as credit_error:
logger.error(
"AUTH: credit_balance failed",
f"AUTH: {payment_method.method_type} credit_balance failed",
extra={
"error": str(credit_error),
"error_type": type(credit_error).__name__,
@@ -224,40 +234,42 @@ async def validate_bearer_key(
if msats <= 0:
logger.error(
"Token redemption returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
"Payment processing returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "...", "method_type": payment_method.method_type},
)
raise Exception("Token redemption failed")
raise Exception("Payment processing failed")
await session.refresh(new_key)
await session.commit()
logger.info(
"New Cashu token successfully redeemed and stored",
f"New {payment_method.method_type} payment successfully processed and stored",
extra={
"key_hash": hashed_key[:8] + "...",
"redeemed_msats": msats,
"final_balance": new_key.balance,
"method_type": payment_method.method_type,
},
)
return new_key
except Exception as e:
logger.error(
"Cashu token redemption failed",
f"{payment_method.method_type} payment processing failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"token_preview": bearer_key[:20] + "..."
"payment_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"method_type": payment_method.method_type,
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"Invalid or expired Cashu key: {str(e)}",
"message": f"Invalid or expired {payment_method.method_type} payment: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
+52 -23
View File
@@ -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.method_implementations import get_payment_method
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
router = APIRouter()
@@ -87,21 +88,25 @@ async def topup_wallet_endpoint(
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.")
raise HTTPException(status_code=400, detail="A payment token is required.")
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")
payment_data = cashu_token.replace("\n", "").replace("\r", "").replace("\t", "")
payment_method = get_payment_method(payment_data)
if not payment_method:
raise HTTPException(status_code=400, detail="Invalid payment format")
try:
amount_msats = await credit_balance(cashu_token, key, session)
payment_result = await payment_method.credit_balance(payment_data, key, session)
amount_msats = payment_result["amount_msats"]
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 format")
else:
raise HTTPException(status_code=400, detail="Failed to redeem token")
raise HTTPException(status_code=400, detail="Failed to process payment")
except Exception:
raise HTTPException(status_code=500, detail="Internal server error")
return {"msats": amount_msats}
@@ -165,24 +170,48 @@ async def refund_wallet_endpoint(
elif remaining_balance <= 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Determine payment method from stored metadata
# For backward compatibility, default to cashu if refund_mint_url is set
payment_method = None
if key.refund_mint_url:
# Assume cashu if mint_url is present
from .payment.method_implementations import CashuTokenPaymentMethod
payment_method = CashuTokenPaymentMethod()
else:
# Try to detect payment method from refund_address format
if key.refund_address:
if key.refund_address.startswith("ln"):
from .payment.method_implementations import BitcoinLightningPaymentMethod
payment_method = BitcoinLightningPaymentMethod()
else:
# Default to cashu for backward compatibility
from .payment.method_implementations import CashuTokenPaymentMethod
payment_method = CashuTokenPaymentMethod()
else:
# Default to cashu for backward compatibility
from .payment.method_implementations import CashuTokenPaymentMethod
payment_method = CashuTokenPaymentMethod()
# Perform refund operation first, before modifying balance
try:
if key.refund_address:
from .core.settings import settings as global_settings
refund_result = await payment_method.refund_balance(
key, remaining_balance_msats, session
)
if not refund_result["success"]:
raise HTTPException(status_code=500, detail="Refund failed")
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}
result: dict[str, str] = {}
if payment_method.method_type == "cashu":
if key.refund_address:
result["recipient"] = key.refund_address
else:
result["token"] = refund_result["refund_identifier"] or ""
elif payment_method.method_type == "lightning":
result["recipient"] = key.refund_address or ""
result["invoice"] = refund_result["refund_identifier"] or ""
elif payment_method.method_type == "usdt":
result["transaction_hash"] = refund_result["refund_identifier"] or ""
if key.refund_currency == "sat":
result["sats"] = str(remaining_balance_msats // 1000)
+20
View File
@@ -1,8 +1,28 @@
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
from .methods import (
PaymentMethod,
PaymentMethodMetadata,
PaymentResult,
RefundResult,
)
from .method_implementations import (
BitcoinLightningPaymentMethod,
CashuTokenPaymentMethod,
USDTTetherPaymentMethod,
get_payment_method,
)
__all__ = [
"CostData",
"CostDataError",
"MaxCostData",
"calculate_cost",
"PaymentMethod",
"PaymentMethodMetadata",
"PaymentResult",
"RefundResult",
"BitcoinLightningPaymentMethod",
"CashuTokenPaymentMethod",
"USDTTetherPaymentMethod",
"get_payment_method",
]
+334
View File
@@ -0,0 +1,334 @@
from typing import TypedDict
from sqlmodel.ext.asyncio.session import AsyncSession
from ..core.db import ApiKey
from ..core.settings import settings
from ..wallet import credit_balance as wallet_credit_balance, send_token, send_to_lnurl
from .methods import (
PaymentMethod,
PaymentMethodMetadata,
PaymentResult,
RefundResult,
)
class CashuTokenPaymentMethod(PaymentMethod):
"""Payment method for Cashu eCash tokens."""
@property
def method_type(self) -> str:
return "cashu"
async def validate_payment_data(
self, payment_data: str, session: AsyncSession
) -> PaymentMethodMetadata:
if not payment_data or len(payment_data) < 10 or "cashu" not in payment_data:
raise ValueError("Invalid Cashu token format")
try:
from ..wallet import deserialize_token_from_string
token_obj = deserialize_token_from_string(payment_data)
return PaymentMethodMetadata(
mint_url=token_obj.mint,
currency=token_obj.unit,
)
except Exception as e:
raise ValueError(f"Invalid Cashu token: {e}") from e
async def credit_balance(
self, payment_data: str, key: ApiKey, session: AsyncSession
) -> PaymentResult:
from ..wallet import deserialize_token_from_string
token_obj = deserialize_token_from_string(payment_data)
amount_msats = await wallet_credit_balance(payment_data, key, session)
metadata = PaymentMethodMetadata(
mint_url=token_obj.mint,
currency=token_obj.unit,
)
return PaymentResult(amount_msats=amount_msats, metadata=metadata)
async def refund_balance(
self, key: ApiKey, amount_msats: int, session: AsyncSession
) -> RefundResult:
if key.refund_address:
from ..core.settings import settings as global_settings
await send_to_lnurl(
amount_msats // 1000 if key.refund_currency == "sat" else amount_msats,
key.refund_currency or "sat",
key.refund_mint_url or global_settings.primary_mint,
key.refund_address,
)
return RefundResult(
success=True,
refund_identifier=key.refund_address,
metadata=PaymentMethodMetadata(
mint_url=key.refund_mint_url,
currency=key.refund_currency,
),
)
else:
refund_currency = key.refund_currency or "sat"
remaining_balance = (
amount_msats // 1000 if refund_currency == "sat" else amount_msats
)
token = await send_token(
remaining_balance, refund_currency, key.refund_mint_url
)
return RefundResult(
success=True,
refund_identifier=token,
metadata=PaymentMethodMetadata(
mint_url=key.refund_mint_url,
currency=refund_currency,
),
)
def can_handle(self, payment_data: str) -> bool:
return (
payment_data is not None
and len(payment_data) >= 10
and payment_data.startswith("cashu")
)
class BitcoinLightningPaymentMethod(PaymentMethod):
"""Payment method for Bitcoin Lightning Network invoices.
To fully implement this payment method, you would need to:
1. Integrate with a Lightning node (e.g., LND, CLN, or LDK)
2. Implement invoice validation and payment verification
3. Set up webhook handling for payment confirmations
4. Implement proper invoice generation for refunds
5. Handle payment routing and channel management
6. Add support for keysend payments
7. Implement proper error handling for payment failures
8. Add rate limiting and anti-spam measures
9. Store invoice preimages for verification
10. Implement proper database schema for tracking Lightning payments
"""
@property
def method_type(self) -> str:
return "lightning"
async def validate_payment_data(
self, payment_data: str, session: AsyncSession
) -> PaymentMethodMetadata:
if not payment_data or not payment_data.startswith("ln"):
raise ValueError("Invalid Lightning invoice format")
try:
from .lnurl import parse_lightning_invoice_amount
amount_msats = parse_lightning_invoice_amount(payment_data, currency="msat")
return PaymentMethodMetadata(
invoice=payment_data,
currency="msat",
)
except Exception as e:
raise ValueError(f"Invalid Lightning invoice: {e}") from e
async def credit_balance(
self, payment_data: str, key: ApiKey, session: AsyncSession
) -> PaymentResult:
# TODO: Full implementation requires:
# 1. Verify invoice is paid by checking with Lightning node
# 2. Extract payment amount from invoice
# 3. Verify payment preimage matches invoice
# 4. Check for double-spending
# 5. Update database with payment confirmation
# Pseudo-implementation:
from .lnurl import parse_lightning_invoice_amount
amount_msats = parse_lightning_invoice_amount(payment_data, currency="msat")
# In real implementation, you would:
# - Connect to Lightning node: lnd_client = LndClient(...)
# - Verify payment: await lnd_client.verify_payment(payment_data)
# - Get payment details: payment = await lnd_client.get_payment(payment_hash)
# - Check if already processed: await check_payment_already_processed(payment_hash)
metadata = PaymentMethodMetadata(
invoice=payment_data,
currency="msat",
)
return PaymentResult(amount_msats=amount_msats, metadata=metadata)
async def refund_balance(
self, key: ApiKey, amount_msats: int, session: AsyncSession
) -> RefundResult:
# TODO: Full implementation requires:
# 1. Generate Lightning invoice for refund amount
# 2. Store invoice in database with refund mapping
# 3. Pay invoice using Lightning node
# 4. Handle payment failures and retries
# 5. Update refund status in database
# Pseudo-implementation:
if not key.refund_address:
raise ValueError("No refund address configured for Lightning refund")
# In real implementation, you would:
# - Generate invoice: invoice = await lnd_client.add_invoice(amount_msats, memo="Refund")
# - Pay invoice: await lnd_client.send_payment(key.refund_address, amount_msats)
# - Store refund record: await store_refund_record(key.hashed_key, invoice.payment_hash)
refund_invoice = f"lnbc{amount_msats // 1000}u..." # Pseudo invoice
return RefundResult(
success=True,
refund_identifier=refund_invoice,
metadata=PaymentMethodMetadata(
invoice=refund_invoice,
currency="msat",
),
)
def can_handle(self, payment_data: str) -> bool:
return payment_data is not None and payment_data.startswith("ln")
class USDTTetherPaymentMethod(PaymentMethod):
"""Payment method for USDT (Tether) on various blockchains.
To fully implement this payment method, you would need to:
1. Choose blockchain(s) to support (Ethereum, Tron, Polygon, etc.)
2. Set up blockchain node connections (Infura, Alchemy, or self-hosted)
3. Implement USDT contract interaction (ERC-20 or TRC-20)
4. Set up wallet for receiving payments
5. Implement transaction monitoring and confirmation tracking
6. Add support for multiple networks (mainnet, testnet)
7. Implement proper gas fee estimation and handling
8. Add transaction verification and double-spend prevention
9. Implement refund mechanism (send USDT back to user address)
10. Add proper error handling for network issues and failed transactions
11. Implement rate limiting and anti-spam measures
12. Store transaction hashes and block confirmations in database
13. Add support for different USDT versions (USDT, USDT.e, etc.)
"""
@property
def method_type(self) -> str:
return "usdt"
async def validate_payment_data(
self, payment_data: str, session: AsyncSession
) -> PaymentMethodMetadata:
# TODO: Full implementation requires:
# 1. Validate transaction hash format for chosen blockchain
# 2. Verify transaction exists on blockchain
# 3. Check transaction is confirmed (sufficient block confirmations)
# 4. Verify transaction is to our wallet address
# 5. Verify transaction amount matches expected amount
# 6. Check transaction hasn't been used before (double-spend prevention)
# Pseudo-implementation:
if not payment_data or len(payment_data) < 40:
raise ValueError("Invalid transaction hash format")
# In real implementation, you would:
# - Validate format: if not re.match(r'^0x[a-fA-F0-9]{64}$', payment_data): raise ValueError(...)
# - Check transaction: tx = await web3.eth.get_transaction(payment_data)
# - Verify recipient: assert tx['to'].lower() == our_wallet_address.lower()
# - Check confirmations: confirmations = await get_confirmations(tx['blockNumber'])
# - Verify amount: assert tx['value'] == expected_amount
return PaymentMethodMetadata(
transaction_hash=payment_data,
network="ethereum", # or "tron", "polygon", etc.
contract_address="0xdAC17F958D2ee523a2206206994597C13D831ec7", # USDT on Ethereum
)
async def credit_balance(
self, payment_data: str, key: ApiKey, session: AsyncSession
) -> PaymentResult:
# TODO: Full implementation requires:
# 1. Fetch transaction details from blockchain
# 2. Convert USDT amount to millisatoshis (using exchange rate)
# 3. Verify transaction is confirmed and not already processed
# 4. Store transaction record in database
# 5. Update balance atomically
# Pseudo-implementation:
# In real implementation, you would:
# - Get transaction: tx = await web3.eth.get_transaction_receipt(payment_data)
# - Parse USDT transfer event: usdt_amount = parse_usdt_transfer_event(tx['logs'])
# - Get exchange rate: rate = await get_usdt_to_btc_rate()
# - Convert to msats: amount_msats = int(usdt_amount * rate * 100_000_000_000)
# - Check if processed: if await is_transaction_processed(payment_data): raise ValueError("Already processed")
# - Store transaction: await store_transaction_record(key.hashed_key, payment_data, usdt_amount)
amount_msats = 1000000 # Pseudo amount
metadata = PaymentMethodMetadata(
transaction_hash=payment_data,
network="ethereum",
contract_address="0xdAC17F958D2ee523a2206206994597C13D831ec7",
)
return PaymentResult(amount_msats=amount_msats, metadata=metadata)
async def refund_balance(
self, key: ApiKey, amount_msats: int, session: AsyncSession
) -> RefundResult:
# TODO: Full implementation requires:
# 1. Convert millisatoshis to USDT using exchange rate
# 2. Get user's refund address (stored in key metadata)
# 3. Estimate gas fees for transaction
# 4. Send USDT transaction to user's address
# 5. Wait for transaction confirmation
# 6. Store refund transaction hash in database
# 7. Handle transaction failures and retries
# Pseudo-implementation:
if not key.refund_address:
raise ValueError("No refund address configured for USDT refund")
# In real implementation, you would:
# - Get exchange rate: rate = await get_btc_to_usdt_rate()
# - Convert to USDT: usdt_amount = (amount_msats / 100_000_000_000) * rate
# - Build transaction: tx = usdt_contract.functions.transfer(key.refund_address, usdt_amount)
# - Estimate gas: gas = await tx.estimate_gas({'from': our_wallet_address})
# - Send transaction: tx_hash = await tx.transact({'from': our_wallet_address, 'gas': gas})
# - Wait for confirmation: await wait_for_confirmation(tx_hash)
# - Store refund: await store_refund_record(key.hashed_key, tx_hash, usdt_amount)
refund_tx_hash = "0x" + "0" * 64 # Pseudo transaction hash
return RefundResult(
success=True,
refund_identifier=refund_tx_hash,
metadata=PaymentMethodMetadata(
transaction_hash=refund_tx_hash,
network="ethereum",
contract_address="0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
)
def can_handle(self, payment_data: str) -> bool:
# In real implementation, you might check for specific prefixes or formats
# For now, this is a catch-all that should be called after other methods
return False # Disabled by default, enable when fully implemented
def get_payment_method(payment_data: str) -> PaymentMethod | None:
"""Get the appropriate payment method for the given payment data.
Args:
payment_data: Payment data string to identify
Returns:
PaymentMethod instance or None if no method can handle it
"""
methods: list[PaymentMethod] = [
CashuTokenPaymentMethod(),
BitcoinLightningPaymentMethod(),
USDTTetherPaymentMethod(),
]
for method in methods:
if method.can_handle(payment_data):
return method
return None
+101
View File
@@ -0,0 +1,101 @@
from abc import ABC, abstractmethod
from typing import TypedDict
from sqlmodel.ext.asyncio.session import AsyncSession
from ..core.db import ApiKey
class PaymentMethodMetadata(TypedDict, total=False):
"""Metadata specific to each payment method type."""
mint_url: str | None
currency: str | None
invoice: str | None
transaction_hash: str | None
network: str | None
contract_address: str | None
class PaymentResult(TypedDict):
"""Result of processing a payment."""
amount_msats: int
metadata: PaymentMethodMetadata
class RefundResult(TypedDict):
"""Result of processing a refund."""
success: bool
refund_identifier: str | None
metadata: PaymentMethodMetadata
class PaymentMethod(ABC):
"""Abstract base class for payment methods used for temporary balances."""
@property
@abstractmethod
def method_type(self) -> str:
"""Return the payment method type identifier (e.g., 'cashu', 'lightning', 'usdt')."""
pass
@abstractmethod
async def validate_payment_data(
self, payment_data: str, session: AsyncSession
) -> PaymentMethodMetadata:
"""Validate payment data and extract metadata.
Args:
payment_data: Payment data string (token, invoice, etc.)
session: Database session
Returns:
PaymentMethodMetadata with extracted information
Raises:
ValueError: If payment data is invalid
"""
pass
@abstractmethod
async def credit_balance(
self, payment_data: str, key: ApiKey, session: AsyncSession
) -> PaymentResult:
"""Credit balance from payment data.
Args:
payment_data: Payment data string (token, invoice, etc.)
key: API key to credit
session: Database session
Returns:
PaymentResult with credited amount and metadata
"""
pass
@abstractmethod
async def refund_balance(
self, key: ApiKey, amount_msats: int, session: AsyncSession
) -> RefundResult:
"""Refund remaining balance.
Args:
key: API key with balance to refund
amount_msats: Amount to refund in millisatoshis
session: Database session
Returns:
RefundResult with refund details
"""
pass
@abstractmethod
def can_handle(self, payment_data: str) -> bool:
"""Check if this payment method can handle the given payment data.
Args:
payment_data: Payment data string to check
Returns:
True if this method can handle the payment data
"""
pass