mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92d7b7e86f |
+24
-141
@@ -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.methods import TEMPORARY_BALANCE_PAYMENT_METHODS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -104,151 +102,36 @@ async def validate_bearer_key(
|
||||
extra={"key_preview": bearer_key[:10] + "..."},
|
||||
)
|
||||
|
||||
if bearer_key.startswith("cashu"):
|
||||
for method in TEMPORARY_BALANCE_PAYMENT_METHODS:
|
||||
if not method.supports(bearer_key):
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
"Processing Cashu token",
|
||||
"Delegating bearer key handling to payment method",
|
||||
extra={
|
||||
"token_preview": bearer_key[:20] + "...",
|
||||
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
|
||||
"method": method.method_name,
|
||||
"key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
)
|
||||
|
||||
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] + "..."}
|
||||
return await method.ensure_api_key(
|
||||
bearer_key,
|
||||
session,
|
||||
refund_address,
|
||||
key_expiry_time,
|
||||
)
|
||||
|
||||
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:
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - we need the original error preserved
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
"Payment method failed to process bearer key",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": bearer_key[:20] + "..."
|
||||
"method": method.method_name,
|
||||
"error": str(exc),
|
||||
"key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
@@ -257,12 +140,12 @@ async def validate_bearer_key(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
||||
"message": "Unable to process payment for provided key",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
) from exc
|
||||
|
||||
logger.error(
|
||||
"Invalid API key format",
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .methods import (
|
||||
CashuPaymentMethod,
|
||||
BitcoinLightningPaymentMethod,
|
||||
TemporaryBalancePaymentMethod,
|
||||
TEMPORARY_BALANCE_PAYMENT_METHODS,
|
||||
UsdtTetherPaymentMethod,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
"CostDataError",
|
||||
"MaxCostData",
|
||||
"calculate_cost",
|
||||
"TemporaryBalancePaymentMethod",
|
||||
"CashuPaymentMethod",
|
||||
"BitcoinLightningPaymentMethod",
|
||||
"UsdtTetherPaymentMethod",
|
||||
"TEMPORARY_BALANCE_PAYMENT_METHODS",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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):
|
||||
"""Interface for temporary balance payment methods."""
|
||||
|
||||
method_name: str = "abstract"
|
||||
|
||||
@abstractmethod
|
||||
def supports(self, bearer_key: str) -> bool:
|
||||
"""Return True when this method can process the provided bearer key."""
|
||||
|
||||
@abstractmethod
|
||||
async def ensure_api_key(
|
||||
self,
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: str | None,
|
||||
key_expiry_time: int | None,
|
||||
) -> ApiKey:
|
||||
"""Return an ApiKey by creating or updating state for the bearer key."""
|
||||
|
||||
|
||||
class CashuPaymentMethod(TemporaryBalancePaymentMethod):
|
||||
"""Existing Cashu-backed temporary balance workflow."""
|
||||
|
||||
method_name = "cashu"
|
||||
|
||||
def supports(self, bearer_key: str) -> bool:
|
||||
return bearer_key.startswith("cashu")
|
||||
|
||||
async def ensure_api_key(
|
||||
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 = deserialize_token_from_string(bearer_key)
|
||||
except Exception as exc: # noqa: BLE001 - we need the original error
|
||||
logger.error(
|
||||
"Failed to decode Cashu token",
|
||||
extra={
|
||||
"error": str(exc),
|
||||
"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(exc)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
) from exc
|
||||
|
||||
if existing_key := await session.get(ApiKey, hashed_key):
|
||||
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
|
||||
|
||||
if token.mint in settings.cashu_mints:
|
||||
refund_currency = token.unit
|
||||
refund_mint_url = token.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 Exception("Failed to fetch key after IntegrityError") # noqa: TRY002
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
class BitcoinLightningPaymentMethod(TemporaryBalancePaymentMethod):
|
||||
"""Placeholder Lightning payment method."""
|
||||
|
||||
method_name = "bitcoin-lightning"
|
||||
|
||||
def supports(self, bearer_key: str) -> bool:
|
||||
return bearer_key.lower().startswith("lnbc") or bearer_key.lower().startswith(
|
||||
"lightning:"
|
||||
)
|
||||
|
||||
async def ensure_api_key(
|
||||
self,
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: str | None,
|
||||
key_expiry_time: int | None,
|
||||
) -> ApiKey:
|
||||
logger.info(
|
||||
"Lightning payment placeholder invoked",
|
||||
extra={"token_preview": bearer_key[:20] + "..."},
|
||||
)
|
||||
# Full implementation would need to 1) decode the invoice, 2) verify settlement
|
||||
# with a Lightning node or service, and 3) credit the ApiKey once payment clears.
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Bitcoin Lightning payments are not yet supported",
|
||||
"type": "payment_method_not_available",
|
||||
"code": "lightning_not_implemented",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UsdtTetherPaymentMethod(TemporaryBalancePaymentMethod):
|
||||
"""Placeholder USDT (on-chain / custodial) payment method."""
|
||||
|
||||
method_name = "usdtether"
|
||||
|
||||
def supports(self, bearer_key: str) -> bool:
|
||||
return bearer_key.lower().startswith("usdt:")
|
||||
|
||||
async def ensure_api_key(
|
||||
self,
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: str | None,
|
||||
key_expiry_time: int | None,
|
||||
) -> ApiKey:
|
||||
logger.info(
|
||||
"USDT payment placeholder invoked",
|
||||
extra={"token_preview": bearer_key[:20] + "..."},
|
||||
)
|
||||
# A production-ready version would need to integrate with a stablecoin custody
|
||||
# provider, track confirmations, perform AML checks, and finally mint balances.
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "USDT payments are not yet supported",
|
||||
"type": "payment_method_not_available",
|
||||
"code": "usdt_not_implemented",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
TEMPORARY_BALANCE_PAYMENT_METHODS: list[TemporaryBalancePaymentMethod] = [
|
||||
CashuPaymentMethod(),
|
||||
BitcoinLightningPaymentMethod(),
|
||||
UsdtTetherPaymentMethod(),
|
||||
]
|
||||
|
||||
__all__ = [
|
||||
"TemporaryBalancePaymentMethod",
|
||||
"CashuPaymentMethod",
|
||||
"BitcoinLightningPaymentMethod",
|
||||
"UsdtTetherPaymentMethod",
|
||||
"TEMPORARY_BALANCE_PAYMENT_METHODS",
|
||||
]
|
||||
Reference in New Issue
Block a user