Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
6ca8499dae Refactor: Extract temporary balance payment methods
Move payment method logic into separate classes for better organization and extensibility.

Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-11-16 02:31:08 +00:00
2 changed files with 198 additions and 162 deletions

View File

@@ -1,9 +1,7 @@
import hashlib
import math
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, update
from .core import get_logger
@@ -15,7 +13,7 @@ from .payment.cost_caculation import (
MaxCostData,
calculate_cost,
)
from .wallet import credit_balance, deserialize_token_from_string
from .payment.temporary_balance import resolve_temporary_balance_payment_method
logger = get_logger(__name__)
@@ -32,8 +30,7 @@ async def validate_bearer_key(
) -> ApiKey:
"""
Validates the provided API key using SQLModel.
If it's a cashu key, it redeems it and stores its hash and balance.
Otherwise checks if the hash of the key exists.
Temporary balance credentials are delegated to the configured payment methods.
"""
logger.debug(
"Starting bearer key validation",
@@ -104,165 +101,17 @@ async def validate_bearer_key(
extra={"key_preview": bearer_key[:10] + "..."},
)
if bearer_key.startswith("cashu"):
if method := resolve_temporary_balance_payment_method(bearer_key):
logger.debug(
"Processing Cashu token",
extra={
"token_preview": bearer_key[:20] + "...",
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
},
"Delegating temporary balance handling",
extra={"method": method.name},
)
return await method.provision(
bearer_key,
session,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
)
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
token_obj = deserialize_token_from_string(bearer_key)
logger.debug(
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
)
if existing_key := await session.get(ApiKey, hashed_key):
logger.info(
"Existing Cashu token found",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"balance": existing_key.balance,
"total_requests": existing_key.total_requests,
},
)
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",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"expiry_time": key_expiry_time,
},
)
if refund_address is not None:
existing_key.refund_address = refund_address
logger.debug(
"Updated refund address for existing Cashu key",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"refund_address_preview": refund_address[:20] + "..."
if len(refund_address) > 20
else refund_address,
},
)
return existing_key
logger.info(
"Creating new Cashu token entry",
extra={
"hash_preview": hashed_key[:16] + "...",
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
},
)
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
new_key = ApiKey(
hashed_key=hashed_key,
balance=0,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
refund_currency=refund_currency,
refund_mint_url=refund_mint_url,
)
session.add(new_key)
try:
await session.flush()
except IntegrityError:
await session.rollback()
logger.info(
"Concurrent key creation detected, fetching existing key",
extra={"key_hash": hashed_key[:8] + "..."},
)
existing_key = await session.get(ApiKey, hashed_key)
if not existing_key:
raise Exception("Failed to fetch existing key after IntegrityError")
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
if refund_address is not None:
existing_key.refund_address = refund_address
return existing_key
logger.debug(
"New key created, starting token redemption",
extra={"key_hash": hashed_key[:8] + "..."},
)
logger.info(
"AUTH: About to call credit_balance",
extra={"token_preview": bearer_key[:50]},
)
try:
msats = await credit_balance(bearer_key, new_key, session)
logger.info(
"AUTH: credit_balance returned successfully", extra={"msats": msats}
)
except Exception as credit_error:
logger.error(
"AUTH: credit_balance failed",
extra={
"error": str(credit_error),
"error_type": type(credit_error).__name__,
},
)
raise credit_error
if msats <= 0:
logger.error(
"Token redemption returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
)
raise Exception("Token redemption failed")
await session.refresh(new_key)
await session.commit()
logger.info(
"New Cashu token successfully redeemed and stored",
extra={
"key_hash": hashed_key[:8] + "...",
"redeemed_msats": msats,
"final_balance": new_key.balance,
},
)
return new_key
except Exception as e:
logger.error(
"Cashu token redemption failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"token_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"Invalid or expired Cashu key: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
logger.error(
"Invalid API key format",

View File

@@ -0,0 +1,187 @@
from __future__ import annotations
import hashlib
from abc import ABC, abstractmethod
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from ..core import get_logger
from ..core.db import ApiKey, AsyncSession
from ..core.settings import settings
from ..wallet import credit_balance, deserialize_token_from_string
logger = get_logger(__name__)
class TemporaryBalancePaymentMethod(ABC):
name: str
@abstractmethod
def can_handle(self, bearer_key: str) -> bool:
raise NotImplementedError
@abstractmethod
async def provision(
self,
bearer_key: str,
session: AsyncSession,
*,
refund_address: str | None,
key_expiry_time: int | None,
) -> ApiKey:
raise NotImplementedError
class CashuPaymentMethod(TemporaryBalancePaymentMethod):
name = "cashu"
def can_handle(self, bearer_key: str) -> bool:
return bearer_key.startswith("cashu")
async def provision(
self,
bearer_key: str,
session: AsyncSession,
*,
refund_address: str | None,
key_expiry_time: int | None,
) -> ApiKey:
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
token_obj = deserialize_token_from_string(bearer_key)
except Exception as exc:
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"Invalid or expired Cashu key: {str(exc)}",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
) from exc
if existing_key := await session.get(ApiKey, hashed_key):
self._apply_metadata(existing_key, refund_address, key_expiry_time)
return existing_key
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
new_key = ApiKey(
hashed_key=hashed_key,
balance=0,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
refund_currency=refund_currency,
refund_mint_url=refund_mint_url,
)
session.add(new_key)
try:
await session.flush()
except IntegrityError:
await session.rollback()
existing_key = await session.get(ApiKey, hashed_key)
if not existing_key:
raise HTTPException(
status_code=500,
detail="Failed to finalize Cashu key creation",
)
self._apply_metadata(existing_key, refund_address, key_expiry_time)
return existing_key
msats = await credit_balance(bearer_key, new_key, session)
if msats <= 0:
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "Token redemption failed",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
await session.refresh(new_key)
await session.commit()
return new_key
@staticmethod
def _apply_metadata(
key: ApiKey, refund_address: str | None, key_expiry_time: int | None
) -> None:
if key_expiry_time is not None:
key.key_expiry_time = key_expiry_time
if refund_address is not None:
key.refund_address = refund_address
class LightningInvoicePaymentMethod(TemporaryBalancePaymentMethod):
name = "lightning"
def can_handle(self, bearer_key: str) -> bool:
normalized = bearer_key.lower()
return normalized.startswith("lnbc") or normalized.startswith("lnurl")
async def provision(
self,
bearer_key: str,
session: AsyncSession,
*,
refund_address: str | None,
key_expiry_time: int | None,
) -> ApiKey:
# Full implementation would need invoice settlement proofs plus a swap service that returns sats-on-mint credits.
raise HTTPException(
status_code=501,
detail="Lightning-backed temporary balances are not available yet.",
)
class UsdtCustodialPaymentMethod(TemporaryBalancePaymentMethod):
name = "usdt"
def can_handle(self, bearer_key: str) -> bool:
normalized = bearer_key.lower()
return normalized.startswith("usdt") or normalized.startswith("tether:")
async def provision(
self,
bearer_key: str,
session: AsyncSession,
*,
refund_address: str | None,
key_expiry_time: int | None,
) -> ApiKey:
# Full implementation would require integrating with a custodial USDT issuer that can escrow funds and settle into msats.
raise HTTPException(
status_code=501,
detail="USDT-backed temporary balances are not available yet.",
)
_PAYMENT_METHODS: list[TemporaryBalancePaymentMethod] = [
CashuPaymentMethod(),
LightningInvoicePaymentMethod(),
UsdtCustodialPaymentMethod(),
]
def resolve_temporary_balance_payment_method(
bearer_key: str,
) -> TemporaryBalancePaymentMethod | None:
for method in _PAYMENT_METHODS:
if method.can_handle(bearer_key):
logger.debug(
"Selected temporary balance payment method",
extra={"method": method.name},
)
return method
return None