mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-11 11:47:50 +00:00
alot payment and auth changes
This commit is contained in:
+53
-87
@@ -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
|
||||
|
||||
|
||||
+13
-8
@@ -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(),
|
||||
|
||||
+88
-16
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user