diff --git a/router/auth.py b/router/auth.py index ce06795b..a3082ff1 100644 --- a/router/auth.py +++ b/router/auth.py @@ -6,8 +6,8 @@ from typing import Literal from fastapi import HTTPException from pydantic import BaseModel, Field -from .redeem import redeem -from .db import ApiKey, create_session +from .redeem import credit_balance +from .db import ApiKey, AsyncSession from .price import btc_usd_ask_price RECIEIVE_LN_ADDRESS = os.environ["RECIEIVE_LN_ADDRESS"] @@ -21,74 +21,53 @@ COST_PER_1K_OUTPUT_TOKENS = ( MODEL_BASED_PRICING = os.environ.get("MODEL_BASED_PRICING", "false").lower() == "true" -def _hash_api_key(api_key: str) -> str: - """Hashes the API key using SHA256.""" - return hashlib.sha256(api_key.encode()).hexdigest() - - -async def validate_api_key(api_key: str) -> ApiKey: +async def validate_bearer_key(bearer_key: str, session: AsyncSession) -> 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. """ - if not api_key: + if not bearer_key: raise HTTPException(status_code=401, detail="api-key or cashu-token required") - hashed_key = _hash_api_key(api_key) + if bearer_key.startswith("sk-"): + if exsisting_key := await session.get(ApiKey, bearer_key[3:]): + return exsisting_key - async with create_session() as session: - if key := await session.get(ApiKey, hashed_key): - return key - - if api_key.startswith("cashu"): - try: - # Redeem the original cashu key - amount = await redeem(api_key, RECIEIVE_LN_ADDRESS) - amount_msats = amount * 1000 # Convert sats to msats - # Store the hash and the redeemed amount using SQLModel - new_key = ApiKey(hashed_key=hashed_key, balance=amount_msats) - session.add(new_key) - await session.commit() - await session.refresh(new_key) - return new_key - except Exception as e: - print(f"Redemption failed: {e}") - raise HTTPException( - status_code=401, detail=f"Invalid or expired cashu key: {e}" - ) - if api_key.startswith("sk-"): - if exsisting_key := await session.get(ApiKey, api_key[3:]): + if bearer_key.startswith("cashu"): + try: + hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest() + if exsisting_key := await session.get(ApiKey, hashed_key): return exsisting_key - - raise HTTPException(status_code=401, detail="Invalid API key") - - -async def pay_for_request(api_key: str) -> None: - """Deducts the cost of a request from the balance associated with the API key hash using SQLModel.""" - hashed_key = _hash_api_key(api_key) - - async with create_session() as session: - key_record = await session.get(ApiKey, hashed_key) - - if not key_record: # This should not happen - raise HTTPException(status_code=401, detail="API key not validated") - - if key_record.balance < COST_PER_REQUEST: + new_key = ApiKey(hashed_key=hashed_key, balance=0) + await credit_balance(bearer_key, new_key, session) + await session.refresh(new_key) + return new_key + except Exception as e: + print(f"Redemption failed: {e}") raise HTTPException( - status_code=402, detail="Insufficient balance" - ) # 402 Payment Required - - # Charge the base cost for the request - key_record.balance -= COST_PER_REQUEST - key_record.total_spent += COST_PER_REQUEST - key_record.total_requests += 1 - session.add(key_record) - await session.commit() - await session.refresh(key_record) + status_code=401, detail=f"Invalid or expired cashu key: {e}" + ) + print(bearer_key) + raise HTTPException(status_code=401, detail="Invalid API key") -async def adjust_payment_for_tokens(api_key: str, response_data: dict) -> dict: +async def pay_for_request(key: ApiKey, session: AsyncSession) -> None: + if key.balance < COST_PER_REQUEST: + raise HTTPException(status_code=402, detail="Insufficient balance") + + # Charge the base cost for the request + key.balance -= COST_PER_REQUEST + key.total_spent += COST_PER_REQUEST + key.total_requests += 1 + session.add(key) + await session.commit() + await session.refresh(key) + + +async def adjust_payment_for_tokens( + key: ApiKey, response_data: dict, session: AsyncSession +) -> dict: """ Adjusts the payment based on token usage in the response. This is called after the initial payment and the upstream request is complete. @@ -131,39 +110,26 @@ async def adjust_payment_for_tokens(api_key: str, response_data: dict) -> dict: if cost_difference == 0: return cost_data # No adjustment needed - hashed_key = _hash_api_key(api_key) - - async with create_session() as session: - key_record = await session.get(ApiKey, hashed_key) - - if key_record is None: + if cost_difference > 0: + # Need to charge more + if key.balance < cost_difference: print( - f"Warning: API key not found when adjusting payment: {hashed_key[:10]}..." + f"Warning: Insufficient balance for token-based pricing adjustment: {key.hashed_key[:10]}..." ) - return cost_data - - if cost_difference > 0: - # Need to charge more - if key_record.balance < cost_difference: - print( - f"Warning: Insufficient balance for token-based pricing adjustment: {hashed_key[:10]}..." - ) - # Still proceed but log the issue - we already provided the service - else: - key_record.balance -= cost_difference - key_record.total_spent += cost_difference - cost_data["total_msats"] = COST_PER_REQUEST + cost_difference + # Still proceed but log the issue - we already provided the service else: - # Refund some of the base cost - refund = abs(cost_difference) - key_record.balance += refund - key_record.total_spent -= refund - cost_data["total_msats"] = COST_PER_REQUEST - refund + key.balance -= cost_difference + key.total_spent += cost_difference + cost_data["total_msats"] = COST_PER_REQUEST + cost_difference + else: + # Refund some of the base cost + refund = abs(cost_difference) + key.balance += refund + key.total_spent -= refund + cost_data["total_msats"] = COST_PER_REQUEST - refund - session.add(key_record) - await session.commit() - - print("cost_data:", cost_data) + session.add(key) + await session.commit() return cost_data diff --git a/router/proxy.py b/router/proxy.py index 0f8ab726..30ac020e 100644 --- a/router/proxy.py +++ b/router/proxy.py @@ -1,11 +1,12 @@ import os import json -from fastapi import APIRouter, Request, BackgroundTasks +from fastapi import APIRouter, Request, BackgroundTasks, Depends from fastapi.responses import Response, StreamingResponse import httpx import re -from .auth import validate_api_key, pay_for_request, adjust_payment_for_tokens +from .auth import validate_bearer_key, pay_for_request, adjust_payment_for_tokens +from .db import AsyncSession, get_session UPSTREAM_BASE_URL = os.environ["UPSTREAM_BASE_URL"] UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "") @@ -16,12 +17,14 @@ proxy_router = APIRouter() @proxy_router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"] ) -async def proxy(request: Request, path: str): +async def proxy( + request: Request, path: str, session: AsyncSession = Depends(get_session) +): auth = request.headers.get("Authorization", "") - api_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else "" + bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else "" - await validate_api_key(api_key) - await pay_for_request(api_key) + key = await validate_bearer_key(bearer_key, session) + await pay_for_request(key, session) # Prepare headers, removing sensitive/problematic ones headers = dict(request.headers) @@ -100,7 +103,7 @@ async def proxy(request: Request, path: str): ): # Found usage data, calculate cost cost_data = await adjust_payment_for_tokens( - api_key, data + key, data, session ) # Format as SSE and yield cost_json = json.dumps({"cost": cost_data}) @@ -136,7 +139,9 @@ async def proxy(request: Request, path: str): try: content = await response.aread() response_json = json.loads(content) - cost_data = await adjust_payment_for_tokens(api_key, response_json) + cost_data = await adjust_payment_for_tokens( + key, response_json, session + ) response_json["cost"] = cost_data return Response( content=json.dumps(response_json).encode(), diff --git a/router/redeem.py b/router/redeem.py index 3a5d3f58..d6d8e238 100644 --- a/router/redeem.py +++ b/router/redeem.py @@ -1,21 +1,32 @@ import httpx -import os from cashu.core.base import Token # type: ignore from cashu.wallet.wallet import Wallet # type: ignore -from cashu.core.settings import settings # type: ignore from cashu.wallet.helpers import deserialize_token_from_string, receive # type: ignore +from .db import ApiKey, AsyncSession -async def _initialize_wallet(mint_url: str) -> Wallet: +WALLET = None + + +async def _initialize_wallet(mint_url: str | None = None) -> Wallet: """Initializes and loads a Cashu wallet.""" + global WALLET + if WALLET is not None: + return WALLET + if mint_url is None: + mint_url = "https://mint.minibits.cash/Bitcoin" wallet = await Wallet.with_db( mint_url, - db=os.path.join(settings.cashu_dir, "temp"), + db=".", load_all_keysets=True, + unit="sat", # todo change to msat ) await wallet.load_mint_info() + if not hasattr(wallet, "keyset_id") or wallet.keyset_id is None: + await wallet.activate_keyset() await wallet.load_proofs(reload=True) + WALLET = wallet return wallet @@ -29,16 +40,15 @@ async def _handle_token_receive(wallet: Wallet, token_obj: Token) -> int: if amount_received <= 0: raise ValueError("Token contained no value.") - return amount_received + return amount_received * 1000 -async def _get_lnurl_invoice(callback_url: str, amount_sat: int) -> tuple[str, dict]: +async def _get_lnurl_invoice(callback_url: str, amount_msat: int) -> tuple[str, dict]: """Requests an invoice from the LNURL callback URL.""" - amount_msats = amount_sat * 1000 async with httpx.AsyncClient() as client: response = await client.get( callback_url, - params={"amount": amount_msats}, + params={"amount": amount_msat}, follow_redirects=True, ) response.raise_for_status() # Raise exception for non-2xx status codes @@ -49,11 +59,11 @@ async def _get_lnurl_invoice(callback_url: str, amount_sat: int) -> tuple[str, d async def _pay_invoice_with_cashu( - wallet: Wallet, bolt11_invoice: str, amount_to_send_sat: int + wallet: Wallet, bolt11_invoice: str, amount_to_send_msat: int ) -> int: """Pays a BOLT11 invoice using Cashu proofs via melt.""" - quote = await wallet.melt_quote(bolt11_invoice, amount_to_send_sat) + quote = await wallet.melt_quote(bolt11_invoice, amount_to_send_msat) proofs_to_melt, _ = await wallet.select_to_send( wallet.proofs, quote.amount + quote.fee_reserve @@ -66,6 +76,49 @@ async def _pay_invoice_with_cashu( return quote.amount +async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int: + token_obj: Token = deserialize_token_from_string(cashu_token) + wallet: Wallet = await _initialize_wallet(token_obj.mint) + amount_msats = await _handle_token_receive(wallet, token_obj) + key.balance += amount_msats + session.add(key) + await session.commit() + return amount_msats + + +async def refund_balance(amount: int, key: ApiKey, session: AsyncSession) -> int: + wallet = await _initialize_wallet() + if key.balance < amount: + raise ValueError("Insufficient balance.") + if amount <= 0: + amount = key.balance + key.balance -= amount + session.add(key) + await session.commit() + if key.refund_address is None: + raise ValueError("Refund address not set.") + return await send_to_lnurl(wallet, key.refund_address, amount) + + +async def create_token( + amount_msats: int, mint: str = "https://mint.minibits.cash/Bitcoin" +) -> str: + wallet = await _initialize_wallet(mint) + balance = wallet.available_balance + amount_sats = amount_msats // 1000 + if balance < amount_sats: + raise ValueError("Insufficient balance on mint.") + print(balance, amount_sats) + if balance > amount_sats: + print("splitting") + _, send_proofs = await wallet.split(wallet.proofs, amount_sats) + else: + print("no splitting") + send_proofs = wallet.proofs + token = await wallet._make_tokenv4(send_proofs) + return token.serialize() + + async def redeem(cashu_token: str, lnurl: str) -> int: """ Redeems a Cashu token and sends the amount to an LNURL address. @@ -75,7 +128,7 @@ async def redeem(cashu_token: str, lnurl: str) -> int: lnurl: The LNURL string (can be bech32, user@host, or direct URL). Returns: - The amount in satoshis that was successfully sent. + The amount in millisatoshis that was successfully sent. Raises: Exception: If any step of the process fails (token receive, LNURL fetch, invoice payment). @@ -88,17 +141,36 @@ async def redeem(cashu_token: str, lnurl: str) -> int: # if USE_BALANCE_ON_INVALID_TOKEN: # amount_received = wallet.available_balance + return await send_to_lnurl(wallet, lnurl, amount_received) + + +async def send_to_lnurl(wallet: Wallet, lnurl: str, amount_msat: int) -> int: + """ + Sends funds from a Cashu wallet to an LNURL address. + + Args: + wallet: The initialized Cashu wallet with available balance. + lnurl: The LNURL string (can be bech32, user@host, or direct URL). + amount_msat: The amount in millisatoshis to send. + + Returns: + The amount in millisatoshis that was successfully sent. + + Raises: + ValueError: If amount is outside LNURL limits or other validation errors. + Exception: If LNURL fetch or invoice payment fails. + """ callback_url, min_sendable, max_sendable = await get_lnurl_data(lnurl) - if not (min_sendable <= amount_received * 1000 <= max_sendable): + if not (min_sendable <= amount_msat <= max_sendable): raise ValueError( - f"Amount {amount_received} sat is outside LNURL limits " + f"Amount {amount_msat / 1000} sat is outside LNURL limits " f"({min_sendable / 1000} - {max_sendable / 1000} sat)." ) # subtract estimated fees - amount_to_send = amount_received - int(max(2, amount_received * 0.01)) + amount_to_send = amount_msat - int(max(2000, amount_msat * 0.01)) - # Note: We pass amount_received directly. The actual amount paid might be adjusted + # Note: We pass amount_msat directly. The actual amount paid might be adjusted # slightly by the melt quote based on the invoice details. bolt11_invoice, _ = await _get_lnurl_invoice(callback_url, amount_to_send) @@ -176,7 +248,7 @@ if __name__ == "__main__": # Removed try-except block, script will crash on error print(f"Attempting to redeem token and pay LNURL: {lnurl}") amount_sent = await redeem(cashu_token, lnurl) - print(f"✅ Successfully sent {amount_sent} sat.") + print(f"✅ Successfully sent {amount_sent / 1000} sat ({amount_sent} msat).") # Removed try-except block for KeyboardInterrupt asyncio.run(main())