mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
1 Commits
90da3803c6
...
cursor/abs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ca8499dae |
173
routstr/auth.py
173
routstr/auth.py
@@ -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",
|
||||
|
||||
187
routstr/payment/temporary_balance.py
Normal file
187
routstr/payment/temporary_balance.py
Normal 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
|
||||
Reference in New Issue
Block a user