mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 02:54:37 +00:00
Deploy v0.1.1 to prod
⬆️ v0.1.1
This commit is contained in:
+8
-8
@@ -1,5 +1,5 @@
|
||||
NAME = "Your Routstr Proxy Name"
|
||||
DESCRIPTION = "A short Description"
|
||||
# NAME = "Your Routstr Proxy Name"
|
||||
# DESCRIPTION = "A short Description"
|
||||
|
||||
# Any openai-compatible api endpoint
|
||||
UPSTREAM_BASE_URL="https://api.openai.com/v1"
|
||||
@@ -7,14 +7,14 @@ UPSTREAM_API_KEY="sk-21212121212121212121212121212121"
|
||||
# UPSTREAM_PROVIDER_FEE=1 # 1 = no fees, 1.05 = 5% fees
|
||||
|
||||
# Lightning address used to receive funds
|
||||
RECEIVE_LN_ADDRESS="shroominic@walletofsatoshi.com"
|
||||
|
||||
# When your cashu balance reaches this number of sats, send the funds to RECEIVE_LN_ADDRESS.
|
||||
MINIMUM_PAYOUT = "100"
|
||||
# RECEIVE_LN_ADDRESS="user@minibits.cash"
|
||||
#MINIMUM_PAYOUT = "100"
|
||||
|
||||
# If set to true, pricing is loaded from the file specified by MODELS_PATH
|
||||
# Defaults to "models.json" and falls back to "models.example.json" if missing
|
||||
MODEL_BASED_PRICING = "true"
|
||||
# MODEL_BASED_PRICING = "true"
|
||||
# MODELS_PATH="models.json"
|
||||
|
||||
# Costs in Sats, if MODEL_BASED_PRICING is set to false
|
||||
@@ -27,13 +27,13 @@ MODEL_BASED_PRICING = "true"
|
||||
# ADMIN_PASSWORD=""
|
||||
|
||||
# Public Endpoint
|
||||
HTTP_URL="https://your.domain.com"
|
||||
# HTTP_URL="https://your.domain.com"
|
||||
|
||||
# Tor Endpoint (copy from docker logs)
|
||||
# ONION_URL=".onion"
|
||||
|
||||
RELAYS="wss://relay.routstr.com,wss://relay.nostr.band"
|
||||
CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org"
|
||||
# RELAYS="wss://relay.routstr.com,wss://relay.nostr.band"
|
||||
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org"
|
||||
|
||||
# Development
|
||||
# DEBUG=TRUE
|
||||
|
||||
@@ -26,7 +26,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --dev
|
||||
uv run python setup.py develop
|
||||
|
||||
- name: Run linting with ruff
|
||||
run: |
|
||||
|
||||
+5
-5
@@ -259,7 +259,7 @@ Before requesting review, ensure:
|
||||
|
||||
```text
|
||||
routstr-proxy/
|
||||
├── router/ # Main application code
|
||||
├── routstr/ # Main application code
|
||||
│ ├── core/ # Core functionality
|
||||
│ │ ├── admin.py # Admin interface
|
||||
│ │ ├── db.py # Database models and operations
|
||||
@@ -284,10 +284,10 @@ routstr-proxy/
|
||||
|
||||
### Key Components
|
||||
|
||||
- **FastAPI Application**: Main API server in `router/core/main.py`
|
||||
- **Database Models**: SQLModel definitions in `router/core/db.py`
|
||||
- **Payment Logic**: Cashu integration and cost calculation in `router/payment/`
|
||||
- **Proxy Handler**: Request forwarding logic in `router/proxy.py`
|
||||
- **FastAPI Application**: Main API server in `routstr/core/main.py`
|
||||
- **Database Models**: SQLModel definitions in `routstr/core/db.py`
|
||||
- **Payment Logic**: Cashu integration and cost calculation in `routstr/payment/`
|
||||
- **Proxy Handler**: Request forwarding logic in `routstr/proxy.py`
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ RUN apk add --no-cache \
|
||||
RUN apk add git
|
||||
|
||||
COPY uv.lock pyproject.toml ./
|
||||
RUN mkdir -p /routstr
|
||||
|
||||
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
|
||||
# RUN uv sync
|
||||
@@ -25,4 +26,4 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/.venv/bin/fastapi", "run", "router", "--host", "0.0.0.0"]
|
||||
CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -92,7 +92,7 @@ docker-down:
|
||||
lint:
|
||||
@echo "🔍 Running linting checks..."
|
||||
$(RUFF) check .
|
||||
$(MYPY) router/ --ignore-missing-imports
|
||||
$(MYPY) routstr/ --ignore-missing-imports
|
||||
|
||||
format:
|
||||
@echo "✨ Formatting code..."
|
||||
@@ -101,7 +101,7 @@ format:
|
||||
|
||||
type-check:
|
||||
@echo "🔎 Running type checks..."
|
||||
$(MYPY) router/ --ignore-missing-imports
|
||||
$(MYPY) routstr/ --ignore-missing-imports
|
||||
|
||||
# Development setup
|
||||
dev-setup:
|
||||
@@ -209,7 +209,7 @@ db-clean:
|
||||
# Advanced testing options
|
||||
test-coverage:
|
||||
@echo "📊 Running tests with coverage..."
|
||||
$(PYTEST) --cov=router --cov-report=html --cov-report=term
|
||||
$(PYTEST) --cov=routstr --cov-report=html --cov-report=term
|
||||
@echo "Coverage report generated in htmlcov/"
|
||||
|
||||
test-watch:
|
||||
@@ -228,7 +228,7 @@ ci-test:
|
||||
ci-lint:
|
||||
@echo "🤖 Running CI linting..."
|
||||
$(RUFF) check . --exit-non-zero-on-fix
|
||||
$(MYPY) router/ --ignore-missing-imports --no-error-summary
|
||||
$(MYPY) routstr/ --ignore-missing-imports --no-error-summary
|
||||
|
||||
# Debug helpers
|
||||
test-debug:
|
||||
|
||||
@@ -69,7 +69,7 @@ cp .env.example .env
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
fastapi run router --host 0.0.0.0 --port 8000
|
||||
fastapi run routstr --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The service forwards requests to `UPSTREAM_BASE_URL`. Supply the upstream API key via the `UPSTREAM_API_KEY` environment variable if required.
|
||||
|
||||
+2
-7
@@ -1,9 +1,9 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
router:
|
||||
routstr:
|
||||
build: .
|
||||
command: ["/.venv/bin/fastapi", "dev", "router", "--host", "0.0.0.0", "--port", "8000"]
|
||||
command: ["/.venv/bin/fastapi", "dev", "routstr", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
@@ -27,9 +27,6 @@ services:
|
||||
- "REFUND_PROCESSING_INTERVAL=3600"
|
||||
- "MINIMUM_PAYOUT=1000"
|
||||
- "PAYOUT_INTERVAL=86400"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
- mock-mint
|
||||
- mock-openai
|
||||
@@ -40,8 +37,6 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8088:8080" # host:container
|
||||
volumes:
|
||||
- ./relay-data:/usr/src/app/db
|
||||
environment:
|
||||
- LISTEN_ADDR=0.0.0.0
|
||||
- LISTEN_PORT=8080
|
||||
|
||||
+10
-3
@@ -1,7 +1,7 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
router:
|
||||
routstr:
|
||||
build: .
|
||||
volumes:
|
||||
- .:/app
|
||||
@@ -21,9 +21,16 @@ services:
|
||||
- tor-data:/var/lib/tor
|
||||
environment:
|
||||
# Format: HS_<NAME>=<TARGET_HOST>:<TARGET_PORT>:<VIRTUAL_PORT>
|
||||
- HS_ROUTER=router:8000:80
|
||||
- HS_ROUTER=routstr:8000:80
|
||||
depends_on:
|
||||
- router
|
||||
- routstr
|
||||
|
||||
# Legacy service definition to ensure cleanup of old container
|
||||
router:
|
||||
image: alpine:latest
|
||||
command: /bin/true
|
||||
profiles:
|
||||
- cleanup
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
|
||||
+8
-5
@@ -1,23 +1,26 @@
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
# from logging.config import fileConfig
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
# Add the parent directory to the Python path so we can import router modules
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
from routstr.core.db import DATABASE_URL
|
||||
|
||||
from router.core.db import DATABASE_URL
|
||||
# Add the parent directory to the Python path so we can import routstr modules
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is None:
|
||||
raise ValueError("config_file_name is None")
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Skip loading alembic's logging configuration to preserve our custom logging
|
||||
# fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""introduce reserved balance
|
||||
|
||||
Revision ID: 042f6b77d69d
|
||||
Revises: 898f00ea481e
|
||||
Create Date: 2025-08-18 19:03:09.507368
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "042f6b77d69d"
|
||||
down_revision = "898f00ea481e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("reserved_balance", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("api_keys", "reserved_balance")
|
||||
# ### end Alembic commands ###
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-08-09 13:48:40.648729
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7bc4e8b02b9d"
|
||||
@@ -20,7 +20,7 @@ def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("mint_url", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add mint+currency refund details
|
||||
|
||||
Revision ID: 898f00ea481e
|
||||
Revises: 7bc4e8b02b9d
|
||||
Create Date: 2025-08-13 16:45:42.148314
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "898f00ea481e"
|
||||
down_revision = "7bc4e8b02b9d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("refund_mint_url", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("refund_currency", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.drop_column("api_keys", "mint_url")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("api_keys", sa.Column("mint_url", sa.VARCHAR(), nullable=True))
|
||||
op.drop_column("api_keys", "refund_currency")
|
||||
op.drop_column("api_keys", "refund_mint_url")
|
||||
# ### end Alembic commands ###
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-08-09 13:28:38.537652
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel as sqlm
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f6ce1348e266"
|
||||
@@ -20,9 +20,9 @@ def upgrade() -> None:
|
||||
if "api_keys" not in sa.inspect(op.get_bind()).get_table_names():
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("hashed_key", sqlm.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("hashed_key", sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("balance", sa.Integer(), nullable=False),
|
||||
sa.Column("refund_address", sqlm.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("refund_address", sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("key_expiry_time", sa.Integer(), nullable=True),
|
||||
sa.Column("total_spent", sa.Integer(), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
|
||||
+9
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1b"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -58,6 +58,13 @@ markers = [
|
||||
"performance: marks tests that measure performance metrics",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["routstr"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
@@ -72,8 +79,5 @@ disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.uv.sources]
|
||||
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }
|
||||
routstr = { workspace = true }
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["."]
|
||||
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from ..wallet import get_balance, send_token
|
||||
from .db import ApiKey, create_session
|
||||
|
||||
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
|
||||
|
||||
|
||||
class WithdrawRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
def login_form() -> str:
|
||||
return """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
input[type="password"] {
|
||||
padding: 8px;
|
||||
}
|
||||
button {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('password').value;
|
||||
document.cookie = `admin_password=${password}; path=/; max-age=86400`;
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<form onsubmit="handleSubmit(event)">
|
||||
<input type="password" id="password" placeholder="Admin Password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def info(content: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="text-align: center;">
|
||||
{content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def admin_auth() -> str:
|
||||
if os.getenv("ADMIN_PASSWORD", "") == "":
|
||||
return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.")
|
||||
else:
|
||||
return login_form()
|
||||
|
||||
|
||||
async def dashboard(request: Request) -> str:
|
||||
# fetch cashu / api-key data from database
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ApiKey))
|
||||
api_keys = result.all()
|
||||
|
||||
api_keys_table_rows = []
|
||||
for key in api_keys:
|
||||
expiry_time_utc = (
|
||||
datetime.fromtimestamp(key.key_expiry_time, tz=timezone.utc)
|
||||
if key.key_expiry_time is not None
|
||||
else None
|
||||
)
|
||||
expiry_time_human_readable = (
|
||||
expiry_time_utc.strftime("%Y-%m-%d %H:%M:%S") if expiry_time_utc else ""
|
||||
)
|
||||
|
||||
api_keys_table_rows.append(
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{'{} ({} UTC)'.format(key.key_expiry_time, expiry_time_human_readable) if key.key_expiry_time else key.key_expiry_time}</td></tr>"
|
||||
)
|
||||
|
||||
# Calculate the total balance of all API keys using integer arithmetic to
|
||||
# avoid rounding issues.
|
||||
total_user_balance = sum(key.balance for key in api_keys) // 1000
|
||||
# Fetch balance from cashu
|
||||
current_balance = await get_balance("sat")
|
||||
owner_balance = current_balance - total_user_balance
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}}
|
||||
th, td {{
|
||||
border: 1px solid black;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}}
|
||||
button {{
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}}
|
||||
button:hover {{
|
||||
background-color: #0056b3;
|
||||
}}
|
||||
button:disabled {{
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}}
|
||||
#token-result {{
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
display: none;
|
||||
max-width: 100%;
|
||||
}}
|
||||
#token-text {{
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background-color: #e9ecef;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
.copy-btn {{
|
||||
background-color: #28a745;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.copy-btn:hover {{
|
||||
background-color: #1e7e34;
|
||||
}}
|
||||
.refresh-btn {{
|
||||
background-color: #ffc107;
|
||||
color: black;
|
||||
}}
|
||||
.refresh-btn:hover {{
|
||||
background-color: #e0a800;
|
||||
}}
|
||||
.modal {{
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}}
|
||||
.modal-content {{
|
||||
background-color: #fefefe;
|
||||
margin: 15% auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
width: 300px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}}
|
||||
.close {{
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.close:hover {{
|
||||
color: black;
|
||||
}}
|
||||
input[type="number"] {{
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
.warning {{
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
</style>
|
||||
<script>
|
||||
function openWithdrawModal() {{
|
||||
const modal = document.getElementById('withdraw-modal');
|
||||
const amountInput = document.getElementById('withdraw-amount');
|
||||
amountInput.value = {owner_balance};
|
||||
modal.style.display = 'block';
|
||||
}}
|
||||
|
||||
function closeWithdrawModal() {{
|
||||
const modal = document.getElementById('withdraw-modal');
|
||||
modal.style.display = 'none';
|
||||
}}
|
||||
|
||||
function checkAmount() {{
|
||||
const amount = parseInt(document.getElementById('withdraw-amount').value);
|
||||
const warning = document.getElementById('withdraw-warning');
|
||||
const ownerBalance = {owner_balance};
|
||||
|
||||
if (amount > ownerBalance && amount <= {current_balance}) {{
|
||||
warning.style.display = 'block';
|
||||
}} else {{
|
||||
warning.style.display = 'none';
|
||||
}}
|
||||
}}
|
||||
|
||||
async function performWithdraw() {{
|
||||
const amount = parseInt(document.getElementById('withdraw-amount').value);
|
||||
const button = document.getElementById('confirm-withdraw-btn');
|
||||
const tokenResult = document.getElementById('token-result');
|
||||
|
||||
if (!amount || amount <= 0) {{
|
||||
alert('Please enter a valid amount');
|
||||
return;
|
||||
}}
|
||||
|
||||
if (amount > {current_balance}) {{
|
||||
alert('Amount exceeds wallet balance');
|
||||
return;
|
||||
}}
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Withdrawing...';
|
||||
|
||||
try {{
|
||||
const response = await fetch('/admin/withdraw', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
}},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({{ amount: amount }})
|
||||
}});
|
||||
|
||||
if (response.ok) {{
|
||||
const data = await response.json();
|
||||
document.getElementById('token-text').textContent = data.token;
|
||||
tokenResult.style.display = 'block';
|
||||
closeWithdrawModal();
|
||||
}} else {{
|
||||
const errorData = await response.json();
|
||||
alert('Failed to withdraw balance: ' + (errorData.detail || 'Unknown error'));
|
||||
}}
|
||||
}} catch (error) {{
|
||||
alert('Error: ' + error.message);
|
||||
}} finally {{
|
||||
button.disabled = false;
|
||||
button.textContent = 'Withdraw';
|
||||
}}
|
||||
}}
|
||||
|
||||
function copyToken() {{
|
||||
const tokenText = document.getElementById('token-text');
|
||||
navigator.clipboard.writeText(tokenText.textContent).then(() => {{
|
||||
const copyBtn = document.getElementById('copy-btn');
|
||||
const originalText = copyBtn.textContent;
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => {{
|
||||
copyBtn.textContent = originalText;
|
||||
}}, 2000);
|
||||
}}).catch(err => {{
|
||||
alert('Failed to copy token');
|
||||
}});
|
||||
}}
|
||||
|
||||
function refreshPage() {{
|
||||
window.location.reload();
|
||||
}}
|
||||
|
||||
window.onclick = function(event) {{
|
||||
const modal = document.getElementById('withdraw-modal');
|
||||
if (event.target == modal) {{
|
||||
closeWithdrawModal();
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Dashboard</h1>
|
||||
<h2>Current Cashu Balance</h2>
|
||||
<p>Your Balance: {owner_balance} sats</p>
|
||||
<p>The balance is calculated by subtracting the combined user balance from the total Cashu wallet balance.</p>
|
||||
<p>Total Cashu Balance: {current_balance} sats</p>
|
||||
<p>User Balance: {total_user_balance} sats</p>
|
||||
|
||||
<button id="withdraw-btn" onclick="openWithdrawModal()" {"disabled" if current_balance <= 0 else ""}>
|
||||
Withdraw Balance
|
||||
</button>
|
||||
<button class="refresh-btn" onclick="refreshPage()">
|
||||
Refresh Dashboard
|
||||
</button>
|
||||
|
||||
<div id="withdraw-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeWithdrawModal()">×</span>
|
||||
<h3>Withdraw Balance</h3>
|
||||
<p>Enter amount to withdraw (sats):</p>
|
||||
<input type="number" id="withdraw-amount" min="1" max="{current_balance}" placeholder="Amount in sats" oninput="checkAmount()">
|
||||
<p>Maximum: {current_balance} sats</p>
|
||||
<p>Your recommended balance: {owner_balance} sats</p>
|
||||
<div id="withdraw-warning" class="warning" style="display: none;">
|
||||
⚠️ Warning: Withdrawing more than your balance will use user funds!
|
||||
</div>
|
||||
<button id="confirm-withdraw-btn" onclick="performWithdraw()">Withdraw</button>
|
||||
<button onclick="closeWithdrawModal()" style="background-color: #6c757d;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="token-result">
|
||||
<strong>Withdrawal Token:</strong>
|
||||
<div id="token-text"></div>
|
||||
<button id="copy-btn" class="copy-btn" onclick="copyToken()">Copy Token</button>
|
||||
<p><em>Save this token! It represents your withdrawn balance.</em></p>
|
||||
</div>
|
||||
|
||||
<h2>User's API Keys</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Hashed Key</th>
|
||||
<th>Balance (mSats)</th>
|
||||
<th>Total Spent (mSats)</th>
|
||||
<th>Total Requests</th>
|
||||
<th>Refund Address</th>
|
||||
<th>Refund Time</th>
|
||||
</tr>
|
||||
{"".join(api_keys_table_rows)}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@admin_router.get("/", response_class=HTMLResponse)
|
||||
async def admin(request: Request) -> str:
|
||||
admin_cookie = request.cookies.get("admin_password")
|
||||
if admin_cookie and admin_cookie == os.getenv("ADMIN_PASSWORD"):
|
||||
return await dashboard(request)
|
||||
return admin_auth()
|
||||
|
||||
|
||||
@admin_router.post("/withdraw")
|
||||
async def withdraw(
|
||||
request: Request, withdraw_request: WithdrawRequest
|
||||
) -> dict[str, str]:
|
||||
admin_cookie = request.cookies.get("admin_password")
|
||||
if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"):
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
current_balance = await get_balance("sat")
|
||||
|
||||
if withdraw_request.amount <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Withdrawal amount must be positive"
|
||||
)
|
||||
|
||||
if withdraw_request.amount > current_balance:
|
||||
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
|
||||
|
||||
token = await send_token(withdraw_request.amount, "sat")
|
||||
return {"token": token}
|
||||
@@ -1,250 +0,0 @@
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from cashu.core.base import Token
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
|
||||
from .core import db, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CurrencyUnit(Enum):
|
||||
sat = "sat"
|
||||
msat = "msat"
|
||||
|
||||
CASHU_MINTS = os.environ.get("CASHU_MINTS", "https://mint.minibits.cash/Bitcoin")
|
||||
TRUSTED_MINTS = CASHU_MINTS.split(",")
|
||||
PRIMARY_MINT_URL = TRUSTED_MINTS[0]
|
||||
|
||||
|
||||
async def get_balance(unit: CurrencyUnit | str) -> int:
|
||||
wallet = await Wallet.with_db(
|
||||
PRIMARY_MINT_URL,
|
||||
db=".wallet",
|
||||
load_all_keysets=True,
|
||||
unit=unit,
|
||||
)
|
||||
await wallet.load_proofs()
|
||||
return wallet.available_balance.amount
|
||||
|
||||
|
||||
async def recieve_token(
|
||||
token: str,
|
||||
) -> tuple[int, CurrencyUnit, str]: # amount, unit, mint_url
|
||||
token_obj = deserialize_token_from_string(token)
|
||||
if len(token_obj.keysets) > 1:
|
||||
raise ValueError("Multiple keysets per token currently not supported")
|
||||
|
||||
wallet = await Wallet.with_db(
|
||||
token_obj.mint,
|
||||
db=".wallet",
|
||||
load_all_keysets=True,
|
||||
unit=token_obj.unit,
|
||||
)
|
||||
await wallet.load_mint(token_obj.keysets[0])
|
||||
|
||||
if token_obj.mint not in TRUSTED_MINTS:
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
|
||||
await wallet.redeem(token_obj.proofs)
|
||||
return token_obj.amount, token_obj.unit, token_obj.mint
|
||||
|
||||
|
||||
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
|
||||
"""Internal send function - returns amount and serialized token"""
|
||||
wallet = await Wallet.with_db(
|
||||
mint_url or PRIMARY_MINT_URL, db=".wallet", load_all_keysets=True, unit=unit
|
||||
)
|
||||
await wallet.load_mint()
|
||||
await wallet.load_proofs()
|
||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||
|
||||
send_proofs, fees = await wallet.select_to_send(
|
||||
proofs, amount, set_reserved=True, include_fees=True
|
||||
)
|
||||
token = await wallet.serialize_proofs(
|
||||
send_proofs, include_dleq=False, legacy=False, memo=None
|
||||
)
|
||||
return amount, token
|
||||
|
||||
|
||||
async def send_token(
|
||||
amount: int, unit: CurrencyUnit | str, mint_url: str | None = None
|
||||
) -> str:
|
||||
"""Send token and return serialized token string"""
|
||||
unit_str = unit.value if isinstance(unit, CurrencyUnit) else unit
|
||||
_, token = await send(amount, unit_str, mint_url)
|
||||
return token
|
||||
|
||||
|
||||
async def swap_to_primary_mint(
|
||||
token_obj: Token, token_wallet: Wallet
|
||||
) -> tuple[int, CurrencyUnit, str]:
|
||||
logger.info(
|
||||
"swap_to_primary_mint",
|
||||
extra={
|
||||
"mint": token_obj.mint,
|
||||
"amount": token_obj.amount,
|
||||
"unit": token_obj.unit,
|
||||
},
|
||||
)
|
||||
if token_obj.unit == "sat":
|
||||
amount_msat = token_obj.amount * 1000
|
||||
elif token_obj.unit == "msat":
|
||||
amount_msat = token_obj.amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
estimated_fee_sat = max(amount_msat // 1000 * 0.01, 2)
|
||||
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||
primary_wallet = await Wallet.with_db(
|
||||
PRIMARY_MINT_URL, db=".wallet", load_all_keysets=True, unit="sat"
|
||||
)
|
||||
await primary_wallet.load_mint()
|
||||
|
||||
minted_amount = amount_msat_after_fee // 1000
|
||||
mint_quote = await primary_wallet.request_mint(minted_amount)
|
||||
|
||||
melt_quote = await token_wallet.melt_quote(mint_quote.request)
|
||||
_ = await token_wallet.melt(
|
||||
proofs=token_obj.proofs,
|
||||
invoice=mint_quote.request,
|
||||
fee_reserve_sat=melt_quote.fee_reserve,
|
||||
quote_id=melt_quote.quote,
|
||||
)
|
||||
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
|
||||
|
||||
return minted_amount, CurrencyUnit.sat, PRIMARY_MINT_URL
|
||||
|
||||
|
||||
async def credit_balance(
|
||||
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
|
||||
) -> int:
|
||||
logger.info(
|
||||
"credit_balance: Starting token redemption",
|
||||
extra={"token_preview": cashu_token[:50]},
|
||||
)
|
||||
|
||||
try:
|
||||
amount, unit, mint_url = await recieve_token(cashu_token)
|
||||
logger.info(
|
||||
"credit_balance: Token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
|
||||
if unit == "sat":
|
||||
amount = amount * 1000
|
||||
logger.info(
|
||||
"credit_balance: Converted to msat", extra={"amount_msat": amount}
|
||||
)
|
||||
|
||||
if mint_url != PRIMARY_MINT_URL:
|
||||
logger.error(
|
||||
"credit_balance: Mint URL mismatch",
|
||||
extra={"mint_url": mint_url, "primary_mint": PRIMARY_MINT_URL},
|
||||
)
|
||||
raise ValueError("Mint URL is not supported by this proxy")
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Updating balance",
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
)
|
||||
key.balance += amount
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"credit_balance: Balance updated successfully",
|
||||
extra={"new_balance": key.balance},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Cashu token successfully redeemed and stored",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
return amount
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"credit_balance: Error during token redemption",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def send_to_lnurl(amount: int, unit: CurrencyUnit, lnurl: str) -> dict[str, Any]:
|
||||
"""Send payment to Lightning Address/LNURL"""
|
||||
try:
|
||||
# Create wallet instance for this operation
|
||||
payment_wallet = await Wallet.with_db(
|
||||
PRIMARY_MINT_URL, db=".wallet", load_all_keysets=True, unit=unit
|
||||
)
|
||||
await payment_wallet.load_mint()
|
||||
|
||||
# Convert amount to correct unit
|
||||
if unit == CurrencyUnit.sat and amount < 1000:
|
||||
# Convert sats to msats for small amounts
|
||||
amount_to_send = amount * 1000
|
||||
send_unit = CurrencyUnit.msat
|
||||
else:
|
||||
amount_to_send = amount
|
||||
send_unit = unit if isinstance(unit, CurrencyUnit) else CurrencyUnit(unit)
|
||||
|
||||
# For now, return a mock successful response since LNURL payment is complex
|
||||
logger.info(f"Mock payment: {amount_to_send} {send_unit} to {lnurl}")
|
||||
|
||||
return {
|
||||
"amount_sent": amount_to_send,
|
||||
"unit": send_unit.name,
|
||||
"lnurl": lnurl,
|
||||
"status": "completed"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send to LNURL {lnurl}: {e}")
|
||||
unit_str = unit.value if isinstance(unit, CurrencyUnit) else unit
|
||||
return {
|
||||
"amount_sent": 0,
|
||||
"unit": unit_str,
|
||||
"lnurl": lnurl,
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def periodic_payout() -> None:
|
||||
logger.warning("periodic_payout, temporary not implemented")
|
||||
|
||||
|
||||
# class Proof:
|
||||
# """
|
||||
# Represents an ecash bill
|
||||
# """
|
||||
|
||||
|
||||
# def redeem_to_proofs(self, token: str) -> list[Proof]:
|
||||
# raise NotImplementedError
|
||||
|
||||
|
||||
# class Payment:
|
||||
# """
|
||||
# Stores all cashu payment related data
|
||||
# """
|
||||
|
||||
# def __init__(self, token: str) -> None:
|
||||
# self.initial_token = token
|
||||
# amount, unit, mint_url = self.parse_token(token)
|
||||
# self.amount = amount
|
||||
# self.unit = unit
|
||||
# self.mint_url = mint_url
|
||||
|
||||
# self.claimed_proofs = redeem_to_proofs(token)
|
||||
|
||||
# def parse_token(self, token: str) -> tuple[int, CurrencyUnit, str]:
|
||||
# raise NotImplementedError
|
||||
|
||||
# def refund_full(self) -> None:
|
||||
# raise NotImplementedError
|
||||
|
||||
# def refund_partial(self, amount: int) -> None:
|
||||
# raise NotImplementedError
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -12,8 +13,12 @@ from .payment.cost_caculation import (
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .payment.helpers import get_max_cost_for_model
|
||||
from .wallet import credit_balance
|
||||
from .wallet import (
|
||||
PRIMARY_MINT_URL,
|
||||
TRUSTED_MINTS,
|
||||
credit_balance,
|
||||
deserialize_token_from_string,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -113,6 +118,7 @@ async def validate_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] + "..."}
|
||||
)
|
||||
@@ -159,12 +165,20 @@ async def validate_bearer_key(
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
},
|
||||
)
|
||||
if token_obj.mint in TRUSTED_MINTS:
|
||||
refund_currency = token_obj.unit
|
||||
refund_mint_url = token_obj.mint
|
||||
else:
|
||||
refund_currency = "sat"
|
||||
refund_mint_url = PRIMARY_MINT_URL
|
||||
|
||||
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)
|
||||
await session.flush()
|
||||
@@ -257,10 +271,10 @@ async def validate_bearer_key(
|
||||
)
|
||||
|
||||
|
||||
async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int:
|
||||
async def pay_for_request(
|
||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
model = body["model"]
|
||||
cost_per_request = get_max_cost_for_model(model=model)
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
@@ -268,20 +282,19 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"model": model,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
if key.balance < cost_per_request:
|
||||
if key.total_balance < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.balance,
|
||||
"model": model,
|
||||
"shortfall": cost_per_request - key.total_balance,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -289,7 +302,7 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
@@ -311,8 +324,7 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_per_request)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) - cost_per_request,
|
||||
total_spent=col(ApiKey.total_spent) + cost_per_request,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
@@ -351,7 +363,6 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int
|
||||
"new_balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -365,8 +376,7 @@ async def revert_pay_for_request(
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + cost_per_request,
|
||||
total_spent=col(ApiKey.total_spent) - cost_per_request,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
)
|
||||
)
|
||||
@@ -374,6 +384,14 @@ async def revert_pay_for_request(
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
@@ -424,6 +442,7 @@ async def adjust_payment_for_tokens(
|
||||
# If token-based pricing is enabled and base cost is 0, use token-based cost
|
||||
# Otherwise, token cost is additional to the base cost
|
||||
cost_difference = cost.total_msats - deducted_max_cost
|
||||
total_cost_msats: int = math.ceil(cost.total_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
@@ -446,6 +465,7 @@ async def adjust_payment_for_tokens(
|
||||
await session.commit()
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if cost_difference > 0:
|
||||
# Need to charge more
|
||||
logger.info(
|
||||
@@ -459,6 +479,7 @@ async def adjust_payment_for_tokens(
|
||||
},
|
||||
)
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if key.balance < cost_difference:
|
||||
logger.warning(
|
||||
"Insufficient balance for token-based pricing adjustment",
|
||||
@@ -472,6 +493,7 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
await session.commit()
|
||||
else:
|
||||
# this should never happen why do we handle this???
|
||||
charge_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
@@ -524,13 +546,30 @@ async def adjust_payment_for_tokens(
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + refund,
|
||||
total_spent=col(ApiKey.total_spent) - refund,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
cost.total_msats = deducted_max_cost - refund
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
@@ -1,10 +1,11 @@
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .auth import validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .wallet import CurrencyUnit, credit_balance, send_to_lnurl, send_token
|
||||
from .wallet import PRIMARY_MINT_URL, credit_balance, send_to_lnurl, send_token
|
||||
|
||||
router = APIRouter()
|
||||
balance_router = APIRouter(prefix="/v1/balance")
|
||||
@@ -32,6 +33,17 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_balance(
|
||||
initial_balance_token: str, session: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
key = await validate_bearer_key(initial_balance_token, session)
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
@@ -40,12 +52,22 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
}
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
cashu_token: str
|
||||
|
||||
|
||||
@router.post("/topup")
|
||||
async def topup_wallet_endpoint(
|
||||
cashu_token: str,
|
||||
cashu_token: str | None = None,
|
||||
topup_request: TopupRequest | None = None,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
if topup_request is not None:
|
||||
cashu_token = topup_request.cashu_token
|
||||
if cashu_token is None:
|
||||
raise HTTPException(status_code=400, detail="A cashu_token is required.")
|
||||
|
||||
cashu_token = cashu_token.replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
@@ -69,36 +91,52 @@ async def refund_wallet_endpoint(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
remaining_balance_msats = key.balance
|
||||
remaining_balance_msats: int = key.balance
|
||||
|
||||
if remaining_balance_msats == 0:
|
||||
if remaining_balance_msats <= 0:
|
||||
raise HTTPException(status_code=400, detail="No balance to refund")
|
||||
|
||||
# Perform refund operation first, before modifying balance
|
||||
try:
|
||||
if key.refund_address:
|
||||
await send_to_lnurl(remaining_balance_msats, CurrencyUnit.msat, key.refund_address)
|
||||
result = {"recipient": key.refund_address, "msats": remaining_balance_msats}
|
||||
if key.refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats * 1000
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
key.refund_currency or "sat",
|
||||
key.refund_mint_url or PRIMARY_MINT_URL,
|
||||
key.refund_address,
|
||||
)
|
||||
result = {"recipient": key.refund_address}
|
||||
else:
|
||||
# Convert msats to sats for cashu wallet
|
||||
remaining_balance_sats = remaining_balance_msats // 1000
|
||||
if remaining_balance_sats == 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Balance too small to refund (less than 1 sat)"
|
||||
)
|
||||
refund_amount = (
|
||||
remaining_balance_msats // 1000
|
||||
if key.refund_currency == "sat"
|
||||
else remaining_balance_msats
|
||||
)
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
token = await send_token(
|
||||
refund_amount, refund_currency, key.refund_mint_url
|
||||
)
|
||||
result = {"token": token}
|
||||
|
||||
# TODO: choose currency and mint based on what user has configured
|
||||
token = await send_token(remaining_balance_sats, "sat")
|
||||
if key.refund_currency == "sat":
|
||||
result["sats"] = str(remaining_balance_msats // 1000)
|
||||
else:
|
||||
result["msats"] = str(remaining_balance_msats)
|
||||
|
||||
result = {"msats": remaining_balance_msats, "recipient": None, "token": token}
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
||||
raise
|
||||
except Exception as e:
|
||||
# If refund fails, don't modify the database
|
||||
error_msg = str(e)
|
||||
if ("mint" in error_msg.lower() or "connection" in error_msg.lower() or
|
||||
isinstance(e, Exception) and "ConnectError" in str(type(e))):
|
||||
if (
|
||||
"mint" in error_msg.lower()
|
||||
or "connection" in error_msg.lower()
|
||||
or isinstance(e, Exception)
|
||||
and "ConnectError" in str(type(e))
|
||||
):
|
||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
@@ -0,0 +1,690 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from ..wallet import (
|
||||
TRUSTED_MINTS,
|
||||
fetch_all_balances,
|
||||
get_proofs_per_mint_and_unit,
|
||||
get_wallet,
|
||||
send_token,
|
||||
slow_filter_spend_proofs,
|
||||
)
|
||||
from .db import ApiKey, create_session
|
||||
from .logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
|
||||
|
||||
|
||||
class WithdrawRequest(BaseModel):
|
||||
amount: int
|
||||
mint_url: str | None = None
|
||||
unit: str = "sat"
|
||||
|
||||
|
||||
def login_form() -> str:
|
||||
return """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f5f7fa; }
|
||||
.login-card { background: white; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); width: 320px; }
|
||||
h2 { margin-bottom: 1.5rem; color: #1a202c; text-align: center; }
|
||||
input[type="password"] { width: 100%; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; }
|
||||
input[type="password"]:focus { outline: none; border-color: #4299e1; }
|
||||
button { width: 100%; padding: 12px; margin-top: 1rem; background: #4299e1; color: white; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||||
button:hover { background: #3182ce; transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
</style>
|
||||
<script>
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('password').value;
|
||||
document.cookie = `admin_password=${password}; path=/; max-age=86400`;
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h2>🔐 Admin Login</h2>
|
||||
<form onsubmit="handleSubmit(event)">
|
||||
<input type="password" id="password" placeholder="Admin Password" required autofocus>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def info(content: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f5f7fa; }}
|
||||
.info-card {{ background: white; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); max-width: 500px; text-align: center; }}
|
||||
.info-card p {{ color: #4a5568; font-size: 1.1rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="info-card">
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def admin_auth() -> str:
|
||||
if os.getenv("ADMIN_PASSWORD", "") == "":
|
||||
return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.")
|
||||
else:
|
||||
return login_form()
|
||||
|
||||
|
||||
async def dashboard(request: Request) -> str:
|
||||
# fetch cashu / api-key data from database
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ApiKey))
|
||||
api_keys = result.all()
|
||||
|
||||
api_keys_table_rows = []
|
||||
for key in api_keys:
|
||||
expiry_time_utc = (
|
||||
datetime.fromtimestamp(key.key_expiry_time, tz=timezone.utc)
|
||||
if key.key_expiry_time is not None
|
||||
else None
|
||||
)
|
||||
expiry_time_human_readable = (
|
||||
expiry_time_utc.strftime("%Y-%m-%d %H:%M:%S") if expiry_time_utc else ""
|
||||
)
|
||||
|
||||
api_keys_table_rows.append(
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{'{} ({} UTC)'.format(key.key_expiry_time, expiry_time_human_readable) if key.key_expiry_time else key.key_expiry_time}</td></tr>"
|
||||
)
|
||||
|
||||
# Fetch all balances using the abstracted function
|
||||
(
|
||||
balance_details,
|
||||
total_wallet_balance_sats,
|
||||
total_user_balance_sats,
|
||||
owner_balance,
|
||||
) = await fetch_all_balances()
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #2c3e50; line-height: 1.6; padding: 2rem; }}
|
||||
h1, h2 {{ margin-bottom: 1rem; color: #1a202c; }}
|
||||
h1 {{ font-size: 2rem; }}
|
||||
h2 {{ font-size: 1.5rem; margin-top: 2rem; }}
|
||||
p {{ margin-bottom: 0.5rem; color: #4a5568; }}
|
||||
table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 1rem; }}
|
||||
th {{ background: #4a5568; color: white; font-weight: 600; padding: 12px; text-align: left; }}
|
||||
td {{ padding: 12px; border-bottom: 1px solid #e2e8f0; }}
|
||||
tr:hover {{ background: #f7fafc; }}
|
||||
button {{ padding: 10px 20px; cursor: pointer; background: #4299e1; color: white; border: none; border-radius: 6px; font-weight: 600; margin-right: 10px; transition: all 0.2s; }}
|
||||
button:hover {{ background: #3182ce; transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
||||
button:disabled {{ background: #a0aec0; cursor: not-allowed; transform: none; }}
|
||||
.refresh-btn {{ background: #48bb78; }}
|
||||
.refresh-btn:hover {{ background: #38a169; }}
|
||||
.investigate-btn {{ background: #4299e1; }}
|
||||
.balance-card {{ background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem; }}
|
||||
.balance-item {{ display: flex; justify-content: space-between; margin-bottom: 1rem; }}
|
||||
.balance-label {{ color: #718096; }}
|
||||
.balance-value {{ font-size: 1.5rem; font-weight: 700; color: #2d3748; }}
|
||||
.balance-primary {{ color: #48bb78; }}
|
||||
.currency-grid {{ margin-top: 1rem; font-size: 0.9rem; }}
|
||||
.currency-row {{ display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; align-items: center; }}
|
||||
.currency-row:last-child {{ border-bottom: none; }}
|
||||
.currency-header {{ font-weight: 600; color: #4a5568; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }}
|
||||
.mint-name {{ color: #2d3748; font-size: 0.85rem; word-break: break-all; }}
|
||||
.balance-num {{ text-align: right; font-family: monospace; }}
|
||||
.owner-positive {{ color: #22c55e; }}
|
||||
.error-row {{ color: #dc2626; font-style: italic; }}
|
||||
#token-result {{ margin-top: 20px; padding: 20px; background: #e6fffa; border: 1px solid #38b2ac; border-radius: 8px; display: none; }}
|
||||
#token-text {{ font-family: 'Monaco', monospace; font-size: 13px; background: #2d3748; color: #68d391; padding: 15px; border-radius: 6px; margin: 10px 0; word-break: break-all; }}
|
||||
.copy-btn {{ background: #38a169; padding: 6px 12px; font-size: 14px; }}
|
||||
.copy-btn:hover {{ background: #2f855a; }}
|
||||
.modal {{ display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }}
|
||||
.modal-content {{ background: white; margin: 10% auto; padding: 2rem; width: 90%; max-width: 400px; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; }}
|
||||
@keyframes slideIn {{ from {{ transform: translateY(-20px); opacity: 0; }} to {{ transform: translateY(0); opacity: 1; }} }}
|
||||
.close {{ color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; }}
|
||||
.close:hover {{ color: #2d3748; }}
|
||||
input[type="number"], input[type="text"], select {{ width: 100%; padding: 10px; margin: 10px 0; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; }}
|
||||
input[type="number"]:focus, input[type="text"]:focus, select:focus {{ outline: none; border-color: #4299e1; }}
|
||||
.warning {{ color: #e53e3e; font-weight: 600; margin: 10px 0; padding: 10px; background: #fff5f5; border-radius: 6px; }}
|
||||
</style>
|
||||
<script>
|
||||
const balanceDetails = {json.dumps(balance_details)};
|
||||
|
||||
function openWithdrawModal() {{
|
||||
const modal = document.getElementById('withdraw-modal');
|
||||
updateWithdrawForm();
|
||||
modal.style.display = 'block';
|
||||
}}
|
||||
|
||||
function closeWithdrawModal() {{
|
||||
const modal = document.getElementById('withdraw-modal');
|
||||
modal.style.display = 'none';
|
||||
}}
|
||||
|
||||
function updateWithdrawForm() {{
|
||||
const select = document.getElementById('mint-unit-select');
|
||||
const selectedValue = select.value;
|
||||
if (!selectedValue) return;
|
||||
|
||||
const [mint, unit] = selectedValue.split('|');
|
||||
const detail = balanceDetails.find(d => d.mint_url === mint && d.unit === unit);
|
||||
|
||||
if (detail) {{
|
||||
const amountInput = document.getElementById('withdraw-amount');
|
||||
const maxSpan = document.getElementById('max-amount');
|
||||
const recommendedSpan = document.getElementById('recommended-amount');
|
||||
|
||||
amountInput.max = detail.wallet_balance;
|
||||
amountInput.value = detail.owner_balance > 0 ? detail.owner_balance : 0;
|
||||
maxSpan.textContent = `${{detail.wallet_balance}} ${{unit}}`;
|
||||
recommendedSpan.textContent = `${{detail.owner_balance}} ${{unit}}`;
|
||||
|
||||
checkAmount();
|
||||
}}
|
||||
}}
|
||||
|
||||
function checkAmount() {{
|
||||
const select = document.getElementById('mint-unit-select');
|
||||
const selectedValue = select.value;
|
||||
if (!selectedValue) return;
|
||||
|
||||
const [mint, unit] = selectedValue.split('|');
|
||||
const detail = balanceDetails.find(d => d.mint_url === mint && d.unit === unit);
|
||||
|
||||
if (detail) {{
|
||||
const amount = parseInt(document.getElementById('withdraw-amount').value) || 0;
|
||||
const warning = document.getElementById('withdraw-warning');
|
||||
|
||||
if (amount > detail.owner_balance && amount <= detail.wallet_balance) {{
|
||||
warning.style.display = 'block';
|
||||
}} else {{
|
||||
warning.style.display = 'none';
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
async function performWithdraw() {{
|
||||
const amount = parseInt(document.getElementById('withdraw-amount').value);
|
||||
const select = document.getElementById('mint-unit-select');
|
||||
const selectedValue = select.value;
|
||||
const button = document.getElementById('confirm-withdraw-btn');
|
||||
const tokenResult = document.getElementById('token-result');
|
||||
|
||||
if (!selectedValue) {{
|
||||
alert('Please select a mint and unit');
|
||||
return;
|
||||
}}
|
||||
|
||||
const [mint, unit] = selectedValue.split('|');
|
||||
const detail = balanceDetails.find(d => d.mint_url === mint && d.unit === unit);
|
||||
|
||||
if (!amount || amount <= 0) {{
|
||||
alert('Please enter a valid amount');
|
||||
return;
|
||||
}}
|
||||
|
||||
if (amount > detail.wallet_balance) {{
|
||||
alert('Amount exceeds wallet balance');
|
||||
return;
|
||||
}}
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Withdrawing...';
|
||||
|
||||
try {{
|
||||
const response = await fetch('/admin/withdraw', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
}},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({{
|
||||
amount: amount,
|
||||
mint_url: mint,
|
||||
unit: unit
|
||||
}})
|
||||
}});
|
||||
|
||||
if (response.ok) {{
|
||||
const data = await response.json();
|
||||
document.getElementById('token-text').textContent = data.token;
|
||||
tokenResult.style.display = 'block';
|
||||
closeWithdrawModal();
|
||||
}} else {{
|
||||
const errorData = await response.json();
|
||||
alert('Failed to withdraw balance: ' + (errorData.detail || 'Unknown error'));
|
||||
}}
|
||||
}} catch (error) {{
|
||||
alert('Error: ' + error.message);
|
||||
}} finally {{
|
||||
button.disabled = false;
|
||||
button.textContent = 'Withdraw';
|
||||
}}
|
||||
}}
|
||||
|
||||
function copyToken() {{
|
||||
const tokenText = document.getElementById('token-text');
|
||||
navigator.clipboard.writeText(tokenText.textContent).then(() => {{
|
||||
const copyBtn = document.getElementById('copy-btn');
|
||||
const originalText = copyBtn.textContent;
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => {{
|
||||
copyBtn.textContent = originalText;
|
||||
}}, 2000);
|
||||
}}).catch(err => {{
|
||||
alert('Failed to copy token');
|
||||
}});
|
||||
}}
|
||||
|
||||
function refreshPage() {{
|
||||
window.location.reload();
|
||||
}}
|
||||
|
||||
function openInvestigateModal() {{
|
||||
const modal = document.getElementById('investigate-modal');
|
||||
modal.style.display = 'block';
|
||||
}}
|
||||
|
||||
function closeInvestigateModal() {{
|
||||
const modal = document.getElementById('investigate-modal');
|
||||
modal.style.display = 'none';
|
||||
}}
|
||||
|
||||
function investigateLogs() {{
|
||||
const requestId = document.getElementById('request-id').value.trim();
|
||||
if (!requestId) {{
|
||||
alert('Please enter a Request ID');
|
||||
return;
|
||||
}}
|
||||
window.location.href = `/admin/logs/${{requestId}}`;
|
||||
}}
|
||||
|
||||
window.onclick = function(event) {{
|
||||
const withdrawModal = document.getElementById('withdraw-modal');
|
||||
const investigateModal = document.getElementById('investigate-modal');
|
||||
if (event.target == withdrawModal) {{
|
||||
closeWithdrawModal();
|
||||
}} else if (event.target == investigateModal) {{
|
||||
closeInvestigateModal();
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Dashboard</h1>
|
||||
|
||||
<div class="balance-card">
|
||||
<h2>Cashu Wallet Balance</h2>
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">Your Balance (Total)</span>
|
||||
<span class="balance-value balance-primary">{
|
||||
owner_balance
|
||||
} sats</span>
|
||||
</div>
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">Total Wallet</span>
|
||||
<span class="balance-value">{total_wallet_balance_sats} sats</span>
|
||||
</div>
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">User Balance</span>
|
||||
<span class="balance-value">{total_user_balance_sats} sats</span>
|
||||
</div>
|
||||
<p style="margin-top: 1rem; font-size: 0.9rem; color: #718096;">Your balance = Total wallet - User balance</p>
|
||||
|
||||
<div class="currency-grid">
|
||||
<div class="currency-row currency-header">
|
||||
<div>Mint / Unit</div>
|
||||
<div class="balance-num">Wallet</div>
|
||||
<div class="balance-num">Users</div>
|
||||
<div class="balance-num">Owner</div>
|
||||
</div>
|
||||
{
|
||||
"".join(
|
||||
[
|
||||
f'''<div class="currency-row {"error-row" if detail.get("error") else ""}">
|
||||
<div class="mint-name">{detail["mint_url"].replace("https://", "").replace("http://", "")} • {detail["unit"].upper()}</div>
|
||||
<div class="balance-num">{detail["wallet_balance"] if not detail.get("error") else "error"}</div>
|
||||
<div class="balance-num">{detail["user_balance"] if not detail.get("error") else "-"}</div>
|
||||
<div class="balance-num {"owner-positive" if detail["owner_balance"] > 0 else ""}">{detail["owner_balance"] if not detail.get("error") else "-"}</div>
|
||||
</div>'''
|
||||
for detail in balance_details
|
||||
if detail.get("wallet_balance", 0) > 0 or detail.get("error")
|
||||
]
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="withdraw-btn" onclick="openWithdrawModal()" {
|
||||
"disabled" if total_wallet_balance_sats <= 0 else ""
|
||||
}>
|
||||
💸 Withdraw Balance
|
||||
</button>
|
||||
<button class="refresh-btn" onclick="refreshPage()">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
<button class="investigate-btn" onclick="openInvestigateModal()">
|
||||
🔍 Investigate Logs
|
||||
</button>
|
||||
|
||||
<div id="withdraw-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeWithdrawModal()">×</span>
|
||||
<h3>Withdraw Balance</h3>
|
||||
<p>Select mint and currency:</p>
|
||||
<select id="mint-unit-select" onchange="updateWithdrawForm()">
|
||||
{
|
||||
"".join(
|
||||
[
|
||||
f'<option value="{detail["mint_url"]}|{detail["unit"]}">{detail["mint_url"].replace("https://", "").replace("http://", "")} • {detail["unit"].upper()} ({detail["owner_balance"]})</option>'
|
||||
for detail in balance_details
|
||||
if not detail.get("error") and detail["owner_balance"] > 0
|
||||
]
|
||||
)
|
||||
}
|
||||
</select>
|
||||
<p>Enter amount to withdraw:</p>
|
||||
<input type="number" id="withdraw-amount" min="1" placeholder="Amount" oninput="checkAmount()">
|
||||
<p>Maximum: <span id="max-amount">-</span></p>
|
||||
<p>Your recommended balance: <span id="recommended-amount">-</span></p>
|
||||
<div id="withdraw-warning" class="warning" style="display: none;">
|
||||
⚠️ Warning: Withdrawing more than your balance will use user funds!
|
||||
</div>
|
||||
<button id="confirm-withdraw-btn" onclick="performWithdraw()">💸 Withdraw</button>
|
||||
<button onclick="closeWithdrawModal()" style="background-color: #718096;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="investigate-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeInvestigateModal()">×</span>
|
||||
<h3>Investigate Logs</h3>
|
||||
<p>Enter Request ID to investigate:</p>
|
||||
<input type="text" id="request-id" placeholder="e.g., 123e4567-e89b-12d3-a456-426614174000" style="width: 100%; padding: 8px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<button onclick="investigateLogs()">🔍 Investigate</button>
|
||||
<button onclick="closeInvestigateModal()" style="background-color: #718096;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="token-result">
|
||||
<strong>Withdrawal Token:</strong>
|
||||
<div id="token-text"></div>
|
||||
<button id="copy-btn" class="copy-btn" onclick="copyToken()">Copy Token</button>
|
||||
<p><em>Save this token! It represents your withdrawn balance.</em></p>
|
||||
</div>
|
||||
|
||||
<h2>Temporary Balances</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Hashed Key</th>
|
||||
<th>Balance (mSats)</th>
|
||||
<th>Total Spent (mSats)</th>
|
||||
<th>Total Requests</th>
|
||||
<th>Refund Address</th>
|
||||
<th>Refund Time</th>
|
||||
</tr>
|
||||
{"".join(api_keys_table_rows)}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@admin_router.get("/", response_class=HTMLResponse)
|
||||
async def admin(request: Request) -> str:
|
||||
admin_cookie = request.cookies.get("admin_password")
|
||||
if admin_cookie and admin_cookie == os.getenv("ADMIN_PASSWORD"):
|
||||
return await dashboard(request)
|
||||
return admin_auth()
|
||||
|
||||
|
||||
@admin_router.get("/logs/{request_id}", response_class=HTMLResponse)
|
||||
async def view_logs(request: Request, request_id: str) -> str:
|
||||
admin_cookie = request.cookies.get("admin_password")
|
||||
if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"):
|
||||
return admin_auth()
|
||||
|
||||
logger.info(f"Investigating logs for request_id: {request_id}")
|
||||
|
||||
# Search for log entries with this request_id
|
||||
log_entries = []
|
||||
logs_dir = Path("logs")
|
||||
|
||||
if logs_dir.exists():
|
||||
# Get all log files sorted by modification time (most recent first)
|
||||
log_files = sorted(
|
||||
logs_dir.glob("*.log"), key=lambda x: x.stat().st_mtime, reverse=True
|
||||
)
|
||||
|
||||
for log_file in log_files[:7]: # Check last 7 days of logs
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
for line in f:
|
||||
if request_id in line:
|
||||
try:
|
||||
# Parse JSON log entry
|
||||
log_data = json.loads(line.strip())
|
||||
log_entries.append(log_data)
|
||||
except json.JSONDecodeError:
|
||||
# If not JSON, include raw line
|
||||
log_entries.append({"raw": line.strip()})
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading log file {log_file}: {e}")
|
||||
|
||||
# Sort entries by timestamp if available
|
||||
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=False)
|
||||
|
||||
# Format log entries for display
|
||||
formatted_logs = []
|
||||
for entry in log_entries:
|
||||
if "raw" in entry:
|
||||
formatted_logs.append(f'<div class="log-entry">{entry["raw"]}</div>')
|
||||
else:
|
||||
# Format JSON log entry
|
||||
timestamp = entry.get("asctime", "Unknown time")
|
||||
level = entry.get("levelname", "INFO")
|
||||
message = entry.get("message", "")
|
||||
pathname = entry.get("pathname", "")
|
||||
lineno = entry.get("lineno", "")
|
||||
|
||||
# Extract additional fields
|
||||
extra_fields = {
|
||||
k: v
|
||||
for k, v in entry.items()
|
||||
if k
|
||||
not in [
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"pathname",
|
||||
"lineno",
|
||||
"name",
|
||||
"version",
|
||||
"request_id",
|
||||
]
|
||||
}
|
||||
|
||||
level_class = level.lower()
|
||||
formatted_entry = f"""
|
||||
<div class="log-entry log-{level_class}">
|
||||
<div class="log-header">
|
||||
<span class="log-timestamp">{timestamp}</span>
|
||||
<span class="log-level">[{level}]</span>
|
||||
<span class="log-location">{pathname}:{lineno}</span>
|
||||
</div>
|
||||
<div class="log-message">{message}</div>
|
||||
"""
|
||||
|
||||
if extra_fields:
|
||||
formatted_entry += '<div class="log-extra">'
|
||||
for key, value in extra_fields.items():
|
||||
formatted_entry += f'<div class="log-field"><strong>{key}:</strong> {json.dumps(value) if isinstance(value, (dict, list)) else value}</div>'
|
||||
formatted_entry += "</div>"
|
||||
|
||||
formatted_entry += "</div>"
|
||||
formatted_logs.append(formatted_entry)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
h1 {{
|
||||
color: #333;
|
||||
}}
|
||||
.back-btn {{
|
||||
padding: 8px 16px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.back-btn:hover {{
|
||||
background-color: #0056b3;
|
||||
}}
|
||||
.log-container {{
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}}
|
||||
.log-entry {{
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background-color: #f9f9f9;
|
||||
}}
|
||||
.log-entry.log-error {{
|
||||
background-color: #fee;
|
||||
border-color: #fcc;
|
||||
}}
|
||||
.log-entry.log-warning {{
|
||||
background-color: #ffc;
|
||||
border-color: #ff9;
|
||||
}}
|
||||
.log-entry.log-debug, .log-entry.log-trace {{
|
||||
background-color: #f0f0f0;
|
||||
border-color: #ccc;
|
||||
}}
|
||||
.log-header {{
|
||||
margin-bottom: 5px;
|
||||
color: #666;
|
||||
}}
|
||||
.log-timestamp {{
|
||||
color: #0066cc;
|
||||
}}
|
||||
.log-level {{
|
||||
font-weight: bold;
|
||||
}}
|
||||
.log-message {{
|
||||
margin: 5px 0;
|
||||
color: #333;
|
||||
}}
|
||||
.log-extra {{
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}}
|
||||
.log-field {{
|
||||
margin: 2px 0;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}}
|
||||
.no-logs {{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px;
|
||||
}}
|
||||
.request-id-display {{
|
||||
background-color: #e9ecef;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
font-family: monospace;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/admin" class="back-btn">← Back to Dashboard</a>
|
||||
<h1>Log Investigation</h1>
|
||||
<div class="request-id-display">
|
||||
<strong>Request ID:</strong> {request_id}
|
||||
</div>
|
||||
<div class="log-container">
|
||||
{"".join(formatted_logs) if formatted_logs else '<div class="no-logs">No log entries found for this Request ID</div>'}
|
||||
</div>
|
||||
<p style="color: #666; margin-top: 20px;">
|
||||
Found {len(log_entries)} log entries • Searched last 7 days of logs
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@admin_router.post("/withdraw")
|
||||
async def withdraw(
|
||||
request: Request, withdraw_request: WithdrawRequest
|
||||
) -> dict[str, str]:
|
||||
admin_cookie = request.cookies.get("admin_password")
|
||||
if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"):
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
# Get wallet and check balance
|
||||
wallet = await get_wallet(
|
||||
withdraw_request.mint_url or TRUSTED_MINTS[0], withdraw_request.unit
|
||||
)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet,
|
||||
withdraw_request.mint_url or TRUSTED_MINTS[0],
|
||||
withdraw_request.unit,
|
||||
not_reserved=True,
|
||||
)
|
||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||
current_balance = sum(proof.amount for proof in proofs)
|
||||
|
||||
if withdraw_request.amount <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Withdrawal amount must be positive"
|
||||
)
|
||||
|
||||
if withdraw_request.amount > current_balance:
|
||||
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
|
||||
|
||||
token = await send_token(
|
||||
withdraw_request.amount, withdraw_request.unit, withdraw_request.mint_url
|
||||
)
|
||||
return {"token": token}
|
||||
@@ -5,7 +5,7 @@ from typing import AsyncGenerator
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, SQLModel
|
||||
from sqlmodel import Field, SQLModel, func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .logging import get_logger
|
||||
@@ -23,6 +23,9 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
|
||||
hashed_key: str = Field(primary_key=True)
|
||||
balance: int = Field(default=0, description="Balance in millisatoshis (msats)")
|
||||
reserved_balance: int = Field(
|
||||
default=0, description="Reserved balance in millisatoshis (msats)"
|
||||
)
|
||||
refund_address: str | None = Field(
|
||||
default=None,
|
||||
description="Lightning address to refund remaining balance after key expires",
|
||||
@@ -35,10 +38,28 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
default=0, description="Total spent in millisatoshis (msats)"
|
||||
)
|
||||
total_requests: int = Field(default=0)
|
||||
mint_url: str | None = Field(
|
||||
refund_mint_url: str | None = Field(
|
||||
default=None,
|
||||
description="URL of the mint used to create the cashu-token",
|
||||
)
|
||||
refund_currency: str | None = Field(
|
||||
default=None,
|
||||
description="Currency of the cashu-token",
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
|
||||
async def balances_for_mint_and_unit(
|
||||
db_session: AsyncSession, mint_url: str, unit: str
|
||||
) -> int:
|
||||
query = select(func.sum(ApiKey.balance)).where(
|
||||
ApiKey.refund_mint_url == mint_url, ApiKey.refund_currency == unit
|
||||
)
|
||||
result = await db_session.exec(query)
|
||||
return result.one() or 0
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
@@ -0,0 +1,57 @@
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handle HTTP exceptions and include request ID in response."""
|
||||
request_id = getattr(request.state, "request_id", "unknown")
|
||||
|
||||
# Get status code and detail - works for both FastAPI and Starlette HTTPException
|
||||
status_code = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
|
||||
logger.warning(
|
||||
"HTTP exception",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"status_code": status_code,
|
||||
"detail": detail,
|
||||
"path": request.url.path,
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"detail": detail,
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handle general exceptions and include request ID in response."""
|
||||
request_id = getattr(request.state, "request_id", "unknown")
|
||||
|
||||
logger.error(
|
||||
"Unhandled exception",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"path": request.url.path,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "Internal server error, please contact support with the request ID.",
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
@@ -89,7 +89,7 @@ def get_package_version() -> str:
|
||||
return version
|
||||
current_path = current_path.parent
|
||||
|
||||
# Fallback: try the simple path resolution (3 levels up for router/logging/logging_config.py)
|
||||
# Fallback: try the simple path resolution (3 levels up for routstr/logging/logging_config.py)
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
with open(pyproject_path, "rb") as f:
|
||||
@@ -115,6 +115,23 @@ class VersionFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
"""Filter to add request ID to all log records."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Add request ID to the log record if available."""
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from .middleware import request_id_context
|
||||
|
||||
request_id = request_id_context.get(None)
|
||||
record.request_id = request_id if request_id else "no-request-id"
|
||||
except ImportError:
|
||||
# If middleware isn't available yet, just use default
|
||||
record.request_id = "no-request-id"
|
||||
return True
|
||||
|
||||
|
||||
class SecurityFilter(logging.Filter):
|
||||
"""Filter to remove sensitive information from logs."""
|
||||
|
||||
@@ -198,12 +215,13 @@ def setup_logging() -> None:
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": jsonlogger.JsonFormatter,
|
||||
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s",
|
||||
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"version_filter": {"()": VersionFilter},
|
||||
"request_id_filter": {"()": RequestIdFilter},
|
||||
"security_filter": {"()": SecurityFilter},
|
||||
},
|
||||
"handlers": {
|
||||
@@ -214,7 +232,7 @@ def setup_logging() -> None:
|
||||
"show_path": False,
|
||||
"rich_tracebacks": True,
|
||||
"markup": True,
|
||||
"filters": ["security_filter"],
|
||||
"filters": ["request_id_filter", "security_filter"],
|
||||
},
|
||||
"file": {
|
||||
"()": DailyRotatingFileHandler,
|
||||
@@ -225,35 +243,45 @@ def setup_logging() -> None:
|
||||
"interval": 1, # Every 1 day
|
||||
"backupCount": 30, # Keep 30 days of logs
|
||||
"atTime": None, # Rotate at midnight (00:00)
|
||||
"filters": ["version_filter", "security_filter"],
|
||||
"filters": ["version_filter", "request_id_filter", "security_filter"],
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"router": {
|
||||
"routstr": {
|
||||
"level": log_level,
|
||||
"handlers": handlers,
|
||||
"propagate": False,
|
||||
},
|
||||
"router.payment": {
|
||||
"routstr.payment": {
|
||||
"level": log_level,
|
||||
"handlers": handlers,
|
||||
"propagate": False,
|
||||
},
|
||||
"router.cashu": {
|
||||
"routstr.proxy": {
|
||||
"level": log_level,
|
||||
"handlers": handlers,
|
||||
"propagate": False,
|
||||
},
|
||||
"router.proxy": {
|
||||
"routstr.auth": {
|
||||
"level": log_level,
|
||||
"handlers": handlers,
|
||||
"propagate": False,
|
||||
},
|
||||
"router.auth": {
|
||||
"routstr.payment.models": {
|
||||
"level": log_level,
|
||||
"handlers": handlers,
|
||||
"propagate": False,
|
||||
},
|
||||
"routstr.core.exceptions": {
|
||||
"level": log_level,
|
||||
"handlers": handlers,
|
||||
"propagate": False,
|
||||
},
|
||||
"routstr.core.middleware": {
|
||||
"level": log_level,
|
||||
"handlers": ["file"],
|
||||
"propagate": False,
|
||||
},
|
||||
# Suppress verbose third-party logging
|
||||
"httpx": {
|
||||
"level": "WARNING",
|
||||
@@ -266,13 +294,13 @@ def setup_logging() -> None:
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"level": log_level, # Use the configured log level instead of WARNING
|
||||
"handlers": handlers, # Use both console and file handlers
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"level": log_level, # Use the configured log level
|
||||
"handlers": handlers, # Use both console and file handlers
|
||||
"propagate": False,
|
||||
},
|
||||
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
|
||||
@@ -5,6 +5,8 @@ from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import providers_router
|
||||
@@ -13,13 +15,15 @@ from ..proxy import proxy_router
|
||||
from ..wallet import periodic_payout
|
||||
from .admin import admin_router
|
||||
from .db import init_db, run_migrations
|
||||
from .exceptions import general_exception_handler, http_exception_handler
|
||||
from .logging import get_logger, setup_logging
|
||||
from .middleware import LoggingMiddleware
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1b"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -91,8 +95,16 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["x-routstr-request-id"],
|
||||
)
|
||||
|
||||
# Add logging middleware
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
# Add exception handlers
|
||||
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
@app.get("/v1/info")
|
||||
@@ -109,6 +121,11 @@ async def info() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> RedirectResponse:
|
||||
return RedirectResponse("/admin/")
|
||||
|
||||
|
||||
app.include_router(models_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(balance_router)
|
||||
@@ -0,0 +1,126 @@
|
||||
import time
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Context variable to store request ID across async context
|
||||
request_id_context: ContextVar[str | None] = ContextVar("request_id")
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to log detailed request and response information."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Generate request ID
|
||||
request_id = str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
|
||||
# Set request ID in context for logging
|
||||
token = request_id_context.set(request_id)
|
||||
|
||||
# Start timing
|
||||
start_time = time.time()
|
||||
|
||||
# Log request details
|
||||
request_body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
# Only read body for non-streaming requests
|
||||
if hasattr(request, "_body"):
|
||||
request_body = await request.body()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract request info
|
||||
client_host = None
|
||||
if request.client:
|
||||
client_host = request.client.host
|
||||
|
||||
# Log incoming request
|
||||
logger.info(
|
||||
"Incoming request",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query_params": dict(request.query_params),
|
||||
"client_host": client_host,
|
||||
"headers": {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() not in ["authorization", "x-cashu", "cookie"]
|
||||
},
|
||||
"body_size": len(request_body) if request_body else 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Log at TRACE level for full body (security filter will redact sensitive data)
|
||||
if request_body and hasattr(logger, "exception"):
|
||||
logger.exception(
|
||||
"Request body",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"body": request_body.decode("utf-8", errors="ignore")[
|
||||
:1000
|
||||
], # Limit size
|
||||
},
|
||||
)
|
||||
|
||||
# Process request
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Calculate duration
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Log response
|
||||
logger.info(
|
||||
"Request completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"client_host": client_host,
|
||||
},
|
||||
)
|
||||
if hasattr(response, "headers"):
|
||||
response.headers["x-routstr-request-id"] = request_id
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Calculate duration
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Log error
|
||||
logger.error(
|
||||
"Request failed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"client_host": client_host,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Reset context
|
||||
request_id_context.reset(token)
|
||||
|
||||
|
||||
__all__ = ["LoggingMiddleware", "request_id_context"]
|
||||
@@ -9,6 +9,10 @@ import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
providers_router = APIRouter(prefix="/v1/providers")
|
||||
|
||||
|
||||
@@ -44,7 +48,7 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, timeout=timeout) as websocket:
|
||||
print("Connected to relay, searching for kind 31338 events")
|
||||
logger.debug("Connected to relay, searching for kind 31338 events")
|
||||
await websocket.send(req_message)
|
||||
|
||||
while True:
|
||||
@@ -54,27 +58,27 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
print(f"Found provider announcement: {event['id']}")
|
||||
logger.debug(f"Found provider announcement: {event['id']}")
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
print("Received EOSE message")
|
||||
logger.debug("Received EOSE message")
|
||||
break
|
||||
elif data[0] == "NOTICE":
|
||||
print(f"Relay notice: {data[1]}")
|
||||
logger.warning(f"Relay notice: {data[1]}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print("Timeout waiting for message")
|
||||
logger.debug("Timeout waiting for message")
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
print("Failed to decode message as JSON")
|
||||
logger.warning("Failed to decode message as JSON")
|
||||
continue
|
||||
|
||||
await websocket.send(json.dumps(["CLOSE", sub_id]))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Query failed: {e}")
|
||||
logger.error(f"Query failed: {e}")
|
||||
|
||||
print(f"Query complete. Found {len(events)} provider announcements")
|
||||
logger.info(f"Query complete. Found {len(events)} provider announcements")
|
||||
return events
|
||||
|
||||
|
||||
@@ -103,7 +107,7 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
|
||||
# Validate required fields
|
||||
if not endpoint_url or not provider_name or not d_tag:
|
||||
print(
|
||||
logger.warning(
|
||||
f"Invalid provider announcement - missing required tags: {event['id']}"
|
||||
)
|
||||
return None
|
||||
@@ -140,7 +144,7 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
|
||||
logger.error(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -221,7 +225,7 @@ async def get_providers(
|
||||
|
||||
# Query multiple relays for provider announcements
|
||||
for relay_url in discovery_relays:
|
||||
print(f"\nQuerying relay for providers: {relay_url}")
|
||||
logger.info(f"Querying relay for providers: {relay_url}")
|
||||
try:
|
||||
events = await query_nostr_relay_for_providers(
|
||||
relay_url=relay_url,
|
||||
@@ -235,13 +239,13 @@ async def get_providers(
|
||||
event_ids.add(event["id"])
|
||||
all_events.append(event)
|
||||
|
||||
print(f"Got {len(events)} provider announcements from {relay_url}")
|
||||
logger.info(f"Got {len(events)} provider announcements from {relay_url}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to query {relay_url}: {e}")
|
||||
logger.error(f"Failed to query {relay_url}: {e}")
|
||||
continue
|
||||
|
||||
print(f"Found {len(all_events)} total unique provider announcements")
|
||||
logger.info(f"Found {len(all_events)} total unique provider announcements")
|
||||
|
||||
# Parse provider announcements according to RIP-02
|
||||
providers = []
|
||||
@@ -250,7 +254,7 @@ async def get_providers(
|
||||
if parsed_provider:
|
||||
providers.append(parsed_provider)
|
||||
|
||||
print(f"Parsed {len(providers)} valid provider announcements")
|
||||
logger.info(f"Parsed {len(providers)} valid provider announcements")
|
||||
|
||||
# Check provider health if requested
|
||||
healthy_providers: list[dict[str, Any]] = []
|
||||
@@ -1,8 +1,8 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Response
|
||||
from fastapi.requests import Request
|
||||
|
||||
from ..core import get_logger
|
||||
from ..wallet import deserialize_token_from_string
|
||||
@@ -19,30 +19,6 @@ if not UPSTREAM_BASE_URL:
|
||||
raise ValueError("Please set the UPSTREAM_BASE_URL environment variable")
|
||||
|
||||
|
||||
def get_cost_per_request(model: str | None = None) -> int:
|
||||
"""Get the cost per request for a given model."""
|
||||
logger.debug(
|
||||
"Calculating cost per request",
|
||||
extra={
|
||||
"model": model,
|
||||
"model_based_pricing": MODEL_BASED_PRICING,
|
||||
"has_models": bool(MODELS),
|
||||
},
|
||||
)
|
||||
|
||||
if MODEL_BASED_PRICING and MODELS and model:
|
||||
cost = get_max_cost_for_model(model=model)
|
||||
logger.debug(
|
||||
"Using model-based cost", extra={"model": model, "cost_msats": cost}
|
||||
)
|
||||
return cost
|
||||
|
||||
logger.debug(
|
||||
"Using default cost per request", extra={"cost_msats": COST_PER_REQUEST}
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
|
||||
|
||||
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
cashu_token = x_cashu
|
||||
@@ -111,7 +87,7 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
||||
)
|
||||
|
||||
|
||||
def get_max_cost_for_model(model: str) -> int:
|
||||
def get_max_cost_for_model(model: str, tolerance_percentage: int = 1) -> int:
|
||||
"""Get the maximum cost for a specific model."""
|
||||
logger.debug(
|
||||
"Getting max cost for model",
|
||||
@@ -142,7 +118,7 @@ def get_max_cost_for_model(model: str) -> int:
|
||||
|
||||
for m in MODELS:
|
||||
if m.id == model:
|
||||
max_cost = m.sats_pricing.max_cost * 1000 # type: ignore
|
||||
max_cost = m.sats_pricing.max_cost * 1000 * (1 - tolerance_percentage / 100) # type: ignore
|
||||
logger.debug(
|
||||
"Found model-specific max cost",
|
||||
extra={"model": model, "max_cost_msats": max_cost},
|
||||
@@ -157,21 +133,13 @@ def get_max_cost_for_model(model: str) -> int:
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_type: str, message: str, status_code: int, token: Optional[str] = None
|
||||
error_type: str,
|
||||
message: str,
|
||||
status_code: int,
|
||||
request: Request,
|
||||
token: str | None = None,
|
||||
) -> Response:
|
||||
"""Create a standardized error response."""
|
||||
logger.info(
|
||||
"Creating error response",
|
||||
extra={
|
||||
"error_type": error_type,
|
||||
"error_message": message,
|
||||
"status_code": status_code,
|
||||
},
|
||||
)
|
||||
|
||||
response_headers = {}
|
||||
if token:
|
||||
response_headers["X-Cashu"] = token
|
||||
return Response(
|
||||
content=json.dumps(
|
||||
{
|
||||
@@ -179,12 +147,13 @@ def create_error_response(
|
||||
"message": message,
|
||||
"type": error_type,
|
||||
"code": status_code,
|
||||
}
|
||||
},
|
||||
"request_id": getattr(request.state, "request_id", "unknown"),
|
||||
}
|
||||
),
|
||||
status_code=status_code,
|
||||
media_type="application/json",
|
||||
headers=dict(response_headers),
|
||||
headers={"X-Cashu": token} if token else {},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
from cashu.wallet.wallet import Proof, Wallet
|
||||
|
||||
try:
|
||||
from bech32 import bech32_decode, convertbits # type: ignore
|
||||
except ModuleNotFoundError: # pragma: no cover – allow runtime miss
|
||||
bech32_decode = None # type: ignore
|
||||
convertbits = None # type: ignore
|
||||
|
||||
|
||||
class LNURLData(TypedDict):
|
||||
"""LNURL payRequest data."""
|
||||
|
||||
callback_url: str
|
||||
min_sendable: int # millisatoshi
|
||||
max_sendable: int # millisatoshi
|
||||
|
||||
|
||||
class LNURLError(Exception):
|
||||
"""LNURL related errors."""
|
||||
|
||||
|
||||
def parse_lightning_invoice_amount(invoice: str, currency: str = "sat") -> int:
|
||||
"""Parse Lightning invoice (BOLT-11) to extract amount in specified currency units.
|
||||
|
||||
Args:
|
||||
invoice: BOLT-11 Lightning invoice string
|
||||
currency: Target currency unit ("sat" or "msat")
|
||||
|
||||
Returns:
|
||||
Amount in the specified currency unit
|
||||
|
||||
Raises:
|
||||
LNURLError: If invoice format is invalid or amount cannot be parsed
|
||||
"""
|
||||
invoice = invoice.lower().strip()
|
||||
|
||||
if not invoice.startswith("ln"):
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
# Find the network part (bc, tb, etc.)
|
||||
network_start = 2
|
||||
while network_start < len(invoice) and invoice[network_start] not in "0123456789":
|
||||
network_start += 1
|
||||
|
||||
if network_start >= len(invoice):
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
# Parse amount and multiplier
|
||||
amount_str = ""
|
||||
multiplier = ""
|
||||
i = network_start
|
||||
|
||||
# Extract numeric part
|
||||
while i < len(invoice) and invoice[i].isdigit():
|
||||
amount_str += invoice[i]
|
||||
i += 1
|
||||
|
||||
# Extract multiplier if present
|
||||
if i < len(invoice) and invoice[i] in "munp":
|
||||
multiplier = invoice[i]
|
||||
i += 1
|
||||
|
||||
# Check if we have the required "1" separator
|
||||
if i >= len(invoice) or invoice[i] != "1":
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
if not amount_str:
|
||||
raise LNURLError("Lightning invoice amount not specified")
|
||||
|
||||
# Convert to base units
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
raise LNURLError("Invalid Lightning invoice amount")
|
||||
|
||||
# Apply multiplier to get millisatoshis
|
||||
if multiplier == "m": # milli = 10^-3
|
||||
amount_msat = amount * 100_000_000 # amount is in BTC * 10^-3
|
||||
elif multiplier == "u": # micro = 10^-6
|
||||
amount_msat = amount * 100_000 # amount is in BTC * 10^-6
|
||||
elif multiplier == "n": # nano = 10^-9
|
||||
amount_msat = amount * 100 # amount is in BTC * 10^-9
|
||||
elif multiplier == "p": # pico = 10^-12
|
||||
amount_msat = amount // 10 # amount is in BTC * 10^-12
|
||||
else:
|
||||
# No multiplier means the amount is in BTC
|
||||
amount_msat = amount * 100_000_000_000 # Convert BTC to msat
|
||||
|
||||
# Convert to target currency unit
|
||||
if currency == "msat":
|
||||
return amount_msat
|
||||
elif currency == "sat":
|
||||
return amount_msat // 1000
|
||||
else:
|
||||
raise LNURLError(f"Unsupported currency for Lightning: {currency}")
|
||||
|
||||
|
||||
async def decode_lnurl(lnurl: str) -> str:
|
||||
"""Decode LNURL to get the actual URL.
|
||||
|
||||
Handles:
|
||||
- lightning: prefix
|
||||
- user@host format
|
||||
- bech32 encoded lnurl
|
||||
- direct HTTPS URLs
|
||||
|
||||
Args:
|
||||
lnurl: LNURL string in any supported format
|
||||
|
||||
Returns:
|
||||
The decoded HTTPS URL
|
||||
|
||||
Raises:
|
||||
LNURLError: If the LNURL format is invalid
|
||||
"""
|
||||
# Remove lightning: prefix if present
|
||||
if lnurl.startswith("lightning:"):
|
||||
lnurl = lnurl[10:]
|
||||
|
||||
# Handle user@host format (Lightning Address)
|
||||
if "@" in lnurl and len(lnurl.split("@")) == 2:
|
||||
user, host = lnurl.split("@")
|
||||
return f"https://{host}/.well-known/lnurlp/{user}"
|
||||
|
||||
# Handle bech32 encoded LNURL
|
||||
if lnurl.lower().startswith("lnurl"):
|
||||
if bech32_decode is None or convertbits is None:
|
||||
raise ImportError(
|
||||
"bech32 library is required for LNURL bech32 decoding. "
|
||||
"Install it with: pip install bech32"
|
||||
)
|
||||
|
||||
try:
|
||||
hrp, data = bech32_decode(lnurl)
|
||||
if data is None:
|
||||
raise LNURLError("Invalid bech32 data in LNURL")
|
||||
|
||||
decoded_data = convertbits(data, 5, 8, False)
|
||||
if decoded_data is None:
|
||||
raise LNURLError("Failed to convert LNURL bits")
|
||||
|
||||
return bytes(decoded_data).decode("utf-8")
|
||||
except Exception as e:
|
||||
raise LNURLError(f"Failed to decode LNURL: {e}") from e
|
||||
|
||||
# Assume it's a direct URL
|
||||
if not lnurl.startswith("https://"):
|
||||
raise LNURLError("Direct LNURL must use HTTPS")
|
||||
|
||||
return lnurl
|
||||
|
||||
|
||||
async def get_lnurl_data(lnurl: str) -> LNURLData:
|
||||
"""Fetch LNURL payRequest data.
|
||||
|
||||
Args:
|
||||
lnurl: LNURL string in any supported format
|
||||
|
||||
Returns:
|
||||
LNURLData with callback URL and sendable amounts
|
||||
|
||||
Raises:
|
||||
LNURLError: If the LNURL data is invalid
|
||||
httpx.HTTPError: If the HTTP request fails
|
||||
"""
|
||||
url = await decode_lnurl(lnurl)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, follow_redirects=True, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
lnurl_data = response.json()
|
||||
|
||||
# Validate payRequest data
|
||||
if lnurl_data.get("tag") != "payRequest":
|
||||
raise LNURLError(
|
||||
f"Invalid LNURL tag: expected 'payRequest', got '{lnurl_data.get('tag')}'"
|
||||
)
|
||||
|
||||
if not isinstance(lnurl_data.get("callback"), str):
|
||||
raise LNURLError("Invalid LNURL payRequest: missing callback URL")
|
||||
|
||||
return LNURLData(
|
||||
callback_url=lnurl_data["callback"],
|
||||
min_sendable=lnurl_data.get("minSendable", 1000), # Default 1 sat
|
||||
max_sendable=lnurl_data.get("maxSendable", 1000000000), # Default 1000 BTC
|
||||
)
|
||||
|
||||
|
||||
async def get_lnurl_invoice(
|
||||
callback_url: str, amount_msat: int
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
"""Request a Lightning invoice from LNURL callback.
|
||||
|
||||
Args:
|
||||
callback_url: The LNURL callback URL
|
||||
amount_msat: Amount in millisatoshi
|
||||
|
||||
Returns:
|
||||
Tuple of (bolt11_invoice, full_response_data)
|
||||
|
||||
Raises:
|
||||
LNURLError: If the response is invalid
|
||||
httpx.HTTPError: If the HTTP request fails
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
callback_url,
|
||||
params={"amount": amount_msat},
|
||||
follow_redirects=True,
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
invoice_data = response.json()
|
||||
|
||||
if "pr" not in invoice_data:
|
||||
# Check if there's an error in the response
|
||||
if "reason" in invoice_data:
|
||||
raise LNURLError(f"LNURL error: {invoice_data['reason']}")
|
||||
raise LNURLError(f"Invalid LNURL invoice response: {invoice_data}")
|
||||
|
||||
return invoice_data["pr"], invoice_data
|
||||
|
||||
|
||||
async def raw_send_to_lnurl(
|
||||
wallet: Wallet, proofs: list[Proof], lnurl: str, unit: str
|
||||
) -> int:
|
||||
"""Send funds to an LNURL address.
|
||||
|
||||
Args:
|
||||
wallet: Wallet instance
|
||||
lnurl: LNURL string (can be lightning:, user@host, bech32, or direct URL)
|
||||
amount: Amount to send in the specified currency unit
|
||||
|
||||
Returns:
|
||||
Amount actually paid in the specified currency unit
|
||||
|
||||
Raises:
|
||||
WalletError: If amount is outside LNURL limits or insufficient balance
|
||||
LNURLError: If LNURL operations fail
|
||||
|
||||
Example:
|
||||
# Send 1000 sats to a Lightning Address
|
||||
paid = await wallet.send_to_lnurl("user@getalby.com", 1000)
|
||||
print(f"Paid {paid} sats")
|
||||
|
||||
# Send USD to Lightning Address
|
||||
paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd")
|
||||
"""
|
||||
total_balance = sum(proof.amount for proof in proofs)
|
||||
lnurl_data = await get_lnurl_data(lnurl)
|
||||
|
||||
if unit == "sat":
|
||||
amount_msat = total_balance * 1000
|
||||
min_sendable_sat = lnurl_data["min_sendable"] // 1000
|
||||
max_sendable_sat = lnurl_data["max_sendable"] // 1000
|
||||
elif unit == "msat":
|
||||
amount_msat = (total_balance // 1000) * 1000
|
||||
min_sendable_sat = lnurl_data["min_sendable"]
|
||||
max_sendable_sat = lnurl_data["max_sendable"]
|
||||
else:
|
||||
raise ValueError(f"Currency {unit} not supported for LNURL")
|
||||
|
||||
if not (lnurl_data["min_sendable"] <= amount_msat <= lnurl_data["max_sendable"]):
|
||||
raise ValueError(
|
||||
f"Amount {total_balance} {unit} is outside LNURL limits "
|
||||
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
||||
)
|
||||
|
||||
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2))
|
||||
estimated_fees_msat = estimated_fees_sat * 1000
|
||||
final_amount = amount_msat - estimated_fees_msat
|
||||
|
||||
bolt11_invoice, _ = await get_lnurl_invoice(
|
||||
lnurl_data["callback_url"], final_amount
|
||||
)
|
||||
|
||||
melt_quote_resp = await wallet.melt_quote(
|
||||
invoice=bolt11_invoice, amount_msat=final_amount
|
||||
)
|
||||
_ = await wallet.melt(
|
||||
proofs=proofs,
|
||||
invoice=bolt11_invoice,
|
||||
fee_reserve_sat=melt_quote_resp.fee_reserve,
|
||||
quote_id=melt_quote_resp.quote,
|
||||
)
|
||||
return final_amount
|
||||
@@ -7,8 +7,11 @@ from urllib.request import urlopen
|
||||
from fastapi import APIRouter
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from .price import sats_usd_ask_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
models_router = APIRouter()
|
||||
|
||||
|
||||
@@ -84,7 +87,7 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
print(f"Error fetching models from OpenRouter API: {e}")
|
||||
logger.error(f"Error fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -101,26 +104,26 @@ def load_models() -> list[Model]:
|
||||
|
||||
# Check if user has actively provided a models.json file
|
||||
if models_path.exists():
|
||||
print(f"Loading models from user-provided file: {models_path}")
|
||||
logger.info(f"Loading models from user-provided file: {models_path}")
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
return [Model(**model) for model in data.get("models", [])]
|
||||
except Exception as e:
|
||||
print(f"Error loading models from {models_path}: {e}")
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
# Fall through to auto-generation
|
||||
|
||||
# Auto-generate models from OpenRouter API
|
||||
print("Auto-generating models from OpenRouter API")
|
||||
logger.info("Auto-generating models from OpenRouter API")
|
||||
source_filter = os.getenv("SOURCE")
|
||||
source_filter = source_filter if source_filter and source_filter.strip() else None
|
||||
|
||||
models_data = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models_data:
|
||||
print("Failed to fetch models from OpenRouter API")
|
||||
logger.error("Failed to fetch models from OpenRouter API")
|
||||
return []
|
||||
|
||||
print(f"Successfully fetched {len(models_data)} models from OpenRouter API")
|
||||
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
|
||||
return [Model(**model) for model in models_data]
|
||||
|
||||
|
||||
@@ -165,7 +168,7 @@ async def update_sats_pricing() -> None:
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print("Error updating sats pricing: ", e)
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
@@ -173,6 +176,6 @@ async def update_sats_pricing() -> None:
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models() -> dict:
|
||||
return {"data": MODELS}
|
||||
@@ -7,20 +7,15 @@ from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from ..core import get_logger
|
||||
from ..wallet import CurrencyUnit, recieve_token, send_token
|
||||
from ..wallet import recieve_token, send_token
|
||||
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .helpers import (
|
||||
UPSTREAM_BASE_URL,
|
||||
create_error_response,
|
||||
get_max_cost_for_model,
|
||||
prepare_upstream_headers,
|
||||
)
|
||||
from .helpers import UPSTREAM_BASE_URL, create_error_response, prepare_upstream_headers
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def x_cashu_handler(
|
||||
request: Request, x_cashu_token: str, path: str
|
||||
request: Request, x_cashu_token: str, path: str, max_cost_for_model: int
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle X-Cashu token payment requests."""
|
||||
logger.info(
|
||||
@@ -44,7 +39,9 @@ async def x_cashu_handler(
|
||||
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
|
||||
)
|
||||
|
||||
return await forward_to_upstream(request, path, headers, amount, unit)
|
||||
return await forward_to_upstream(
|
||||
request, path, headers, amount, unit, max_cost_for_model
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.error(
|
||||
@@ -63,7 +60,8 @@ async def x_cashu_handler(
|
||||
"token_already_spent",
|
||||
"The provided CASHU token has already been spent",
|
||||
400,
|
||||
x_cashu_token,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
if "invalid token" in error_message.lower():
|
||||
@@ -71,12 +69,17 @@ async def x_cashu_handler(
|
||||
"invalid_token",
|
||||
"The provided CASHU token is invalid",
|
||||
400,
|
||||
x_cashu_token,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
if "mint error" in error_message.lower():
|
||||
return create_error_response(
|
||||
"mint_error", f"CASHU mint error: {error_message}", 422, x_cashu_token
|
||||
"mint_error",
|
||||
f"CASHU mint error: {error_message}",
|
||||
422,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
# Generic error for other cases
|
||||
@@ -84,12 +87,18 @@ async def x_cashu_handler(
|
||||
"cashu_error",
|
||||
f"CASHU token processing failed: {error_message}",
|
||||
400,
|
||||
x_cashu_token,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
|
||||
async def forward_to_upstream(
|
||||
request: Request, path: str, headers: dict, amount: int, unit: CurrencyUnit
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward request to upstream and handle the response."""
|
||||
if path.startswith("v1/"):
|
||||
@@ -181,7 +190,9 @@ async def forward_to_upstream(
|
||||
extra={"path": path, "amount": amount, "unit": unit},
|
||||
)
|
||||
|
||||
result = await handle_x_cashu_chat_completion(response, amount, unit)
|
||||
result = await handle_x_cashu_chat_completion(
|
||||
response, amount, unit, max_cost_for_model
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
result.background = background_tasks
|
||||
@@ -217,12 +228,15 @@ async def forward_to_upstream(
|
||||
},
|
||||
)
|
||||
return create_error_response(
|
||||
"internal_error", "An unexpected server error occurred", 500
|
||||
"internal_error",
|
||||
"An unexpected server error occurred",
|
||||
500,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
async def handle_x_cashu_chat_completion(
|
||||
response: httpx.Response, amount: int, unit: CurrencyUnit
|
||||
response: httpx.Response, amount: int, unit: str, max_cost_for_model: int
|
||||
) -> StreamingResponse | Response:
|
||||
"""Handle both streaming and non-streaming chat completion responses with token-based pricing."""
|
||||
logger.debug(
|
||||
@@ -246,10 +260,12 @@ async def handle_x_cashu_chat_completion(
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
return await handle_streaming_response(content_str, response, amount, unit)
|
||||
return await handle_streaming_response(
|
||||
content_str, response, amount, unit, max_cost_for_model
|
||||
)
|
||||
else:
|
||||
return await handle_non_streaming_response(
|
||||
content_str, response, amount, unit
|
||||
content_str, response, amount, unit, max_cost_for_model
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -271,7 +287,11 @@ async def handle_x_cashu_chat_completion(
|
||||
|
||||
|
||||
async def handle_streaming_response(
|
||||
content_str: str, response: httpx.Response, amount: int, unit: CurrencyUnit
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> StreamingResponse:
|
||||
"""Handle Server-Sent Events (SSE) streaming response."""
|
||||
logger.debug(
|
||||
@@ -325,7 +345,7 @@ async def handle_streaming_response(
|
||||
|
||||
response_data = {"usage": usage_data, "model": model}
|
||||
try:
|
||||
cost_data = await get_cost(response_data)
|
||||
cost_data = await get_cost(response_data, max_cost_for_model)
|
||||
if cost_data:
|
||||
if unit == "msat":
|
||||
refund_amount = amount - cost_data.total_msats
|
||||
@@ -393,7 +413,11 @@ async def handle_streaming_response(
|
||||
|
||||
|
||||
async def handle_non_streaming_response(
|
||||
content_str: str, response: httpx.Response, amount: int, unit: CurrencyUnit
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> Response:
|
||||
"""Handle regular JSON response."""
|
||||
logger.debug(
|
||||
@@ -404,7 +428,7 @@ async def handle_non_streaming_response(
|
||||
try:
|
||||
response_json = json.loads(content_str)
|
||||
|
||||
cost_data = await get_cost(response_json)
|
||||
cost_data = await get_cost(response_json, max_cost_for_model)
|
||||
|
||||
if not cost_data:
|
||||
logger.error(
|
||||
@@ -510,21 +534,21 @@ async def handle_non_streaming_response(
|
||||
)
|
||||
|
||||
|
||||
async def get_cost(response_data: dict) -> MaxCostData | CostData | None:
|
||||
async def get_cost(
|
||||
response_data: dict, max_cost_for_model: int
|
||||
) -> MaxCostData | CostData | None:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", "unknown")
|
||||
model = response_data.get("model", None)
|
||||
logger.debug(
|
||||
"Calculating cost for response",
|
||||
extra={"model": model, "has_usage": "usage" in response_data},
|
||||
)
|
||||
|
||||
max_cost = get_max_cost_for_model(model=model)
|
||||
|
||||
match calculate_cost(response_data, max_cost):
|
||||
match calculate_cost(response_data, max_cost_for_model):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost pricing",
|
||||
@@ -563,7 +587,7 @@ async def get_cost(response_data: dict) -> MaxCostData | CostData | None:
|
||||
)
|
||||
|
||||
|
||||
async def send_refund(amount: int, unit: CurrencyUnit, mint: str | None = None) -> str:
|
||||
async def send_refund(amount: int, unit: str, mint: str | None = None) -> str:
|
||||
"""Send a refund using Cashu tokens."""
|
||||
logger.debug(
|
||||
"Creating refund token", extra={"amount": amount, "unit": unit, "mint": mint}
|
||||
@@ -19,7 +19,7 @@ from .payment.helpers import (
|
||||
UPSTREAM_BASE_URL,
|
||||
check_token_balance,
|
||||
create_error_response,
|
||||
get_cost_per_request,
|
||||
get_max_cost_for_model,
|
||||
prepare_upstream_headers,
|
||||
)
|
||||
from .payment.x_cashu import x_cashu_handler
|
||||
@@ -416,7 +416,9 @@ async def forward_to_upstream(
|
||||
else:
|
||||
error_message = f"Error connecting to upstream service: {error_type}"
|
||||
|
||||
return create_error_response("upstream_error", error_message, 502)
|
||||
return create_error_response(
|
||||
"upstream_error", error_message, 502, request=request
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
await client.aclose()
|
||||
@@ -437,7 +439,10 @@ async def forward_to_upstream(
|
||||
)
|
||||
|
||||
return create_error_response(
|
||||
"internal_error", "An unexpected server error occurred", 500
|
||||
"internal_error",
|
||||
"An unexpected server error occurred",
|
||||
500,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@@ -446,6 +451,14 @@ async def proxy(
|
||||
request: Request, path: str, session: AsyncSession = Depends(get_session)
|
||||
) -> Response | StreamingResponse:
|
||||
"""Main proxy endpoint handler."""
|
||||
request_body = await request.body()
|
||||
headers = dict(request.headers)
|
||||
|
||||
if "x-cashu" not in headers and "authorization" not in headers.keys():
|
||||
return create_error_response(
|
||||
"unauthorized", "Unauthorized", 401, request=request
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Received proxy request",
|
||||
extra={
|
||||
@@ -456,9 +469,6 @@ async def proxy(
|
||||
},
|
||||
)
|
||||
|
||||
request_body = await request.body()
|
||||
headers = dict(request.headers)
|
||||
|
||||
# Parse JSON body if present, handle empty/invalid JSON
|
||||
request_body_dict = {}
|
||||
if request_body:
|
||||
@@ -491,9 +501,8 @@ async def proxy(
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
max_cost_for_model = get_cost_per_request(
|
||||
model=request_body_dict.get("model", None)
|
||||
)
|
||||
model = request_body_dict.get("model", "unknown")
|
||||
max_cost_for_model = get_max_cost_for_model(model=model)
|
||||
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||
|
||||
# Handle authentication
|
||||
@@ -505,7 +514,7 @@ async def proxy(
|
||||
"token_preview": x_cashu[:20] + "..." if len(x_cashu) > 20 else x_cashu,
|
||||
},
|
||||
)
|
||||
return await x_cashu_handler(request, x_cashu, path)
|
||||
return await x_cashu_handler(request, x_cashu, path, max_cost_for_model)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
logger.debug(
|
||||
@@ -530,11 +539,10 @@ async def proxy(
|
||||
)
|
||||
|
||||
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
||||
# Prepare headers for upstream
|
||||
# TODO: why is this needed? can we remove it?
|
||||
headers = prepare_upstream_headers(dict(request.headers))
|
||||
return await forward_get_to_upstream(request, path, headers)
|
||||
|
||||
cost_per_request = 0
|
||||
# Only pay for request if we have request body data (for completions endpoints)
|
||||
if request_body_dict:
|
||||
logger.info(
|
||||
@@ -548,7 +556,7 @@ async def proxy(
|
||||
)
|
||||
|
||||
try:
|
||||
await pay_for_request(key, session, request_body_dict)
|
||||
await pay_for_request(key, max_cost_for_model, session)
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
@@ -579,7 +587,7 @@ async def proxy(
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
await revert_pay_for_request(key, session, cost_per_request)
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
logger.warning(
|
||||
"Upstream request failed, revert payment",
|
||||
extra={
|
||||
@@ -587,8 +595,22 @@ async def proxy(
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
"upstream_headers": response.headers
|
||||
if hasattr(response, "headers")
|
||||
else None,
|
||||
"upstream_response": response.body
|
||||
if hasattr(response, "body")
|
||||
else None,
|
||||
},
|
||||
)
|
||||
request_id = (
|
||||
request.state.request_id if hasattr(request.state, "request_id") else None
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Upstream request failed, please contact support with request id: {request_id}",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -729,5 +751,8 @@ async def forward_get_to_upstream(
|
||||
},
|
||||
)
|
||||
return create_error_response(
|
||||
"internal_error", "An unexpected server error occurred", 500
|
||||
"internal_error",
|
||||
"An unexpected server error occurred",
|
||||
500,
|
||||
request=request,
|
||||
)
|
||||
@@ -0,0 +1,374 @@
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
from typing import TypedDict
|
||||
|
||||
from cashu.core.base import Proof, Token
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
|
||||
from .core import db, get_logger
|
||||
from .payment.lnurl import raw_send_to_lnurl
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
CASHU_MINTS = os.environ.get("CASHU_MINTS", "https://mint.minibits.cash/Bitcoin")
|
||||
TRUSTED_MINTS = CASHU_MINTS.split(",")
|
||||
PRIMARY_MINT_URL = TRUSTED_MINTS[0]
|
||||
RECEIVE_LN_ADDRESS = os.environ.get("RECEIVE_LN_ADDRESS", "")
|
||||
|
||||
|
||||
async def get_balance(unit: str) -> int:
|
||||
wallet = await get_wallet(PRIMARY_MINT_URL, unit)
|
||||
return wallet.available_balance.amount
|
||||
|
||||
|
||||
async def recieve_token(
|
||||
token: str,
|
||||
) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
token_obj = deserialize_token_from_string(token)
|
||||
if len(token_obj.keysets) > 1:
|
||||
raise ValueError("Multiple keysets per token currently not supported")
|
||||
|
||||
wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False)
|
||||
wallet.keyset_id = token_obj.keysets[0]
|
||||
|
||||
if token_obj.mint not in TRUSTED_MINTS:
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
|
||||
wallet.verify_proofs_dleq(token_obj.proofs)
|
||||
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
return token_obj.amount, token_obj.unit, token_obj.mint
|
||||
|
||||
|
||||
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
|
||||
"""Internal send function - returns amount and serialized token"""
|
||||
wallet: Wallet = await get_wallet(mint_url or PRIMARY_MINT_URL, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(wallet, mint_url or PRIMARY_MINT_URL, unit)
|
||||
|
||||
send_proofs, _ = await wallet.select_to_send(
|
||||
proofs, amount, set_reserved=True, include_fees=False
|
||||
)
|
||||
token = await wallet.serialize_proofs(
|
||||
send_proofs, include_dleq=False, legacy=False, memo=None
|
||||
)
|
||||
return amount, token
|
||||
|
||||
|
||||
async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str:
|
||||
_, token = await send(amount, unit, mint_url)
|
||||
return token
|
||||
|
||||
|
||||
async def swap_to_primary_mint(
|
||||
token_obj: Token, token_wallet: Wallet
|
||||
) -> tuple[int, str, str]:
|
||||
logger.info(
|
||||
"swap_to_primary_mint",
|
||||
extra={
|
||||
"mint": token_obj.mint,
|
||||
"amount": token_obj.amount,
|
||||
"unit": token_obj.unit,
|
||||
},
|
||||
)
|
||||
# Ensure amount is an integer
|
||||
if not isinstance(token_obj.amount, int):
|
||||
token_amount = int(token_obj.amount)
|
||||
else:
|
||||
token_amount = token_obj.amount
|
||||
|
||||
if token_obj.unit == "sat":
|
||||
amount_msat = token_amount * 1000
|
||||
elif token_obj.unit == "msat":
|
||||
amount_msat = token_amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2))
|
||||
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||
primary_wallet = await get_wallet(PRIMARY_MINT_URL, "sat")
|
||||
|
||||
minted_amount = int(amount_msat_after_fee // 1000)
|
||||
mint_quote = await primary_wallet.request_mint(minted_amount)
|
||||
|
||||
melt_quote = await token_wallet.melt_quote(mint_quote.request)
|
||||
_ = await token_wallet.melt(
|
||||
proofs=token_obj.proofs,
|
||||
invoice=mint_quote.request,
|
||||
fee_reserve_sat=melt_quote.fee_reserve,
|
||||
quote_id=melt_quote.quote,
|
||||
)
|
||||
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
|
||||
|
||||
return int(minted_amount), "sat", PRIMARY_MINT_URL
|
||||
|
||||
|
||||
async def credit_balance(
|
||||
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
|
||||
) -> int:
|
||||
logger.info(
|
||||
"credit_balance: Starting token redemption",
|
||||
extra={"token_preview": cashu_token[:50]},
|
||||
)
|
||||
|
||||
try:
|
||||
amount, unit, mint_url = await recieve_token(cashu_token)
|
||||
logger.info(
|
||||
"credit_balance: Token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
|
||||
if unit == "sat":
|
||||
amount = amount * 1000
|
||||
logger.info(
|
||||
"credit_balance: Converted to msat", extra={"amount_msat": amount}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Updating balance",
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
)
|
||||
key.balance += amount
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"credit_balance: Balance updated successfully",
|
||||
extra={"new_balance": key.balance},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Cashu token successfully redeemed and stored",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
return amount
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"credit_balance: Error during token redemption",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
_wallets: dict[str, Wallet] = {}
|
||||
|
||||
|
||||
async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet:
|
||||
global _wallets
|
||||
id = f"{mint_url}_{unit}"
|
||||
if id not in _wallets:
|
||||
_wallets[id] = await Wallet.with_db(
|
||||
mint_url, db=".wallet", load_all_keysets=True, unit=unit
|
||||
)
|
||||
|
||||
if load:
|
||||
await _wallets[id].load_mint()
|
||||
await _wallets[id].load_proofs(reload=True)
|
||||
return _wallets[id]
|
||||
|
||||
|
||||
def get_proofs_per_mint_and_unit(
|
||||
wallet: Wallet, mint_url: str, unit: str, not_reserved: bool = False
|
||||
) -> list[Proof]:
|
||||
valid_keyset_ids = [
|
||||
k.id
|
||||
for k in wallet.keysets.values()
|
||||
if k.mint_url == mint_url and k.unit.name == unit
|
||||
]
|
||||
proofs = [p for p in wallet.proofs if p.id in valid_keyset_ids]
|
||||
if not_reserved:
|
||||
proofs = [p for p in proofs if not p.reserved]
|
||||
return proofs
|
||||
|
||||
|
||||
async def slow_filter_spend_proofs(proofs: list[Proof], wallet: Wallet) -> list[Proof]:
|
||||
if not proofs:
|
||||
return []
|
||||
_proofs = []
|
||||
_spent_proofs = []
|
||||
for i in range(0, len(proofs), 1000):
|
||||
pb = proofs[i : i + 1000]
|
||||
proof_states = await wallet.check_proof_state(pb)
|
||||
for proof, state in zip(pb, proof_states.states):
|
||||
if str(state.state) != "spent":
|
||||
_proofs.append(proof)
|
||||
else:
|
||||
_spent_proofs.append(proof)
|
||||
await wallet.set_reserved_for_send(_spent_proofs, reserved=True)
|
||||
return _proofs
|
||||
|
||||
|
||||
class BalanceDetail(TypedDict, total=False):
|
||||
mint_url: str
|
||||
unit: str
|
||||
wallet_balance: int
|
||||
user_balance: int
|
||||
owner_balance: int
|
||||
error: str
|
||||
|
||||
|
||||
async def fetch_all_balances(
|
||||
units: list[str] | None = None,
|
||||
) -> tuple[list[BalanceDetail], int, int, int]:
|
||||
"""
|
||||
Fetch balances for all trusted mints and units concurrently.
|
||||
|
||||
Returns:
|
||||
- List of balance details for each mint/unit combination
|
||||
- Total wallet balance in sats
|
||||
- Total user balance in sats
|
||||
- Owner balance in sats (wallet - user)
|
||||
"""
|
||||
if units is None:
|
||||
units = ["sat", "msat"]
|
||||
|
||||
async def fetch_balance(
|
||||
session: db.AsyncSession, mint_url: str, unit: str
|
||||
) -> BalanceDetail:
|
||||
try:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url, unit, not_reserved=True
|
||||
)
|
||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||
user_balance = await db.balances_for_mint_and_unit(session, mint_url, unit)
|
||||
if unit == "sat":
|
||||
user_balance = user_balance // 1000
|
||||
proofs_balance = sum(proof.amount for proof in proofs)
|
||||
|
||||
result: BalanceDetail = {
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"wallet_balance": proofs_balance,
|
||||
"user_balance": user_balance,
|
||||
"owner_balance": proofs_balance - user_balance,
|
||||
}
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting balance for {mint_url} {unit}: {e}")
|
||||
error_result: BalanceDetail = {
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"wallet_balance": 0,
|
||||
"user_balance": 0,
|
||||
"owner_balance": 0,
|
||||
"error": str(e),
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Create tasks for all mint/unit combinations
|
||||
async with db.create_session() as session:
|
||||
tasks = [
|
||||
fetch_balance(session, mint_url, unit)
|
||||
for mint_url in TRUSTED_MINTS
|
||||
for unit in units
|
||||
]
|
||||
|
||||
# Run all tasks concurrently
|
||||
balance_details = list(await asyncio.gather(*tasks))
|
||||
|
||||
# Calculate totals
|
||||
total_wallet_balance_sats = 0
|
||||
total_user_balance_sats = 0
|
||||
|
||||
for detail in balance_details:
|
||||
if not detail.get("error"):
|
||||
# Convert to sats for total calculation
|
||||
unit = detail["unit"]
|
||||
proofs_balance_sats = (
|
||||
detail["wallet_balance"]
|
||||
if unit == "sat"
|
||||
else detail["wallet_balance"] // 1000
|
||||
)
|
||||
user_balance_sats = (
|
||||
detail["user_balance"]
|
||||
if unit == "sat"
|
||||
else detail["user_balance"] // 1000
|
||||
)
|
||||
|
||||
total_wallet_balance_sats += proofs_balance_sats
|
||||
total_user_balance_sats += user_balance_sats
|
||||
|
||||
owner_balance = total_wallet_balance_sats - total_user_balance_sats
|
||||
|
||||
return (
|
||||
balance_details,
|
||||
total_wallet_balance_sats,
|
||||
total_user_balance_sats,
|
||||
owner_balance,
|
||||
)
|
||||
|
||||
|
||||
async def periodic_payout() -> None:
|
||||
if not RECEIVE_LN_ADDRESS:
|
||||
logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout")
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(60 * 5)
|
||||
try:
|
||||
async with db.create_session() as session:
|
||||
for mint_url in TRUSTED_MINTS:
|
||||
for unit in ["sat", "msat"]:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url, unit, not_reserved=True
|
||||
)
|
||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||
user_balance = await db.balances_for_mint_and_unit(
|
||||
session, mint_url, unit
|
||||
)
|
||||
if unit == "sat":
|
||||
user_balance = user_balance // 1000
|
||||
proofs_balance = sum(proof.amount for proof in proofs)
|
||||
available_balance = proofs_balance - user_balance
|
||||
min_amount = 210 if unit == "sat" else 210000
|
||||
if available_balance > min_amount:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet, proofs, RECEIVE_LN_ADDRESS, unit
|
||||
)
|
||||
logger.info(
|
||||
"Payout sent successfully",
|
||||
extra={
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"balance": available_balance,
|
||||
"amount_received": amount_received,
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.sleep(5)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error sending payout: {type(e).__name__}",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||
wallet = await get_wallet(mint, unit)
|
||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
return await raw_send_to_lnurl(wallet, proofs, address, unit)
|
||||
|
||||
|
||||
# class Payment:
|
||||
# """
|
||||
# Stores all cashu payment related data
|
||||
# """
|
||||
|
||||
# def __init__(self, token: str) -> None:
|
||||
# self.initial_token = token
|
||||
# amount, unit, mint_url = self.parse_token(token)
|
||||
# self.amount = amount
|
||||
# self.unit = unit
|
||||
# self.mint_url = mint_url
|
||||
|
||||
# self.claimed_proofs = redeem_to_proofs(token)
|
||||
|
||||
# def parse_token(self, token: str) -> tuple[int, CurrencyUnit, str]:
|
||||
# raise NotImplementedError
|
||||
|
||||
# def refund_full(self) -> None:
|
||||
# raise NotImplementedError
|
||||
|
||||
# def refund_partial(self, amount: int) -> None:
|
||||
# raise NotImplementedError
|
||||
@@ -1,19 +0,0 @@
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name="routstr",
|
||||
version="0.1.0",
|
||||
packages=find_packages(),
|
||||
install_requires=[
|
||||
"fastapi[standard]>=0.115",
|
||||
"aiosqlite>=0.20",
|
||||
"sqlmodel>=0.0.24",
|
||||
"httpx[socks]>=0.25.2",
|
||||
"greenlet>=3.2.1",
|
||||
"python-json-logger>=2.0.0",
|
||||
"cashu",
|
||||
"secp256k1",
|
||||
"marshmallow>=3.13,<4.0",
|
||||
],
|
||||
python_requires=">=3.11",
|
||||
)
|
||||
@@ -8,10 +8,11 @@ import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from router.core.logging import get_logger
|
||||
from routstr.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -25,7 +26,7 @@ if use_local_services:
|
||||
"DATABASE_URL": "sqlite+aiosqlite:///:memory:",
|
||||
"UPSTREAM_BASE_URL": "http://localhost:3000", # Mock OpenAI service
|
||||
"UPSTREAM_API_KEY": "test-upstream-key",
|
||||
"CASHU_MINTS": "http://mint:3338", # Docker service name for router validation
|
||||
"CASHU_MINTS": "http://mint:3338", # Docker service name for routstr validation
|
||||
"MINT": "http://mint:3338",
|
||||
"MINT_URL": "http://mint:3338",
|
||||
"NOSTR_RELAY_URL": "ws://localhost:8088",
|
||||
@@ -63,8 +64,8 @@ else:
|
||||
# Set test environment variables before importing the app
|
||||
os.environ.update(test_env)
|
||||
|
||||
from router.core.db import ApiKey, get_session # noqa: E402
|
||||
from router.core.main import app, lifespan # noqa: E402
|
||||
from routstr.core.db import ApiKey, get_session # noqa: E402
|
||||
from routstr.core.main import app, lifespan # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -157,7 +158,7 @@ class TestmintWallet:
|
||||
mint_response = await wallet.mint(amount=amount, hash=quote)
|
||||
token = mint_response.token
|
||||
|
||||
# Replace connection URL with Docker service name for router validation
|
||||
# Replace connection URL with Docker service name for routstr validation
|
||||
if self.connection_url != self.mint_url:
|
||||
token = token.replace(self.connection_url, self.mint_url)
|
||||
|
||||
@@ -251,7 +252,7 @@ class TestmintWallet:
|
||||
async def send_token(
|
||||
self, amount: int, unit: str, mint_url: Optional[str] = None
|
||||
) -> str:
|
||||
"""Send token with compatible signature for mocking router.wallet.send_token"""
|
||||
"""Send token with compatible signature for mocking routstr.wallet.send_token"""
|
||||
return await self.send(amount)
|
||||
|
||||
async def send_to_lnurl(self, lnurl: str, amount: int) -> int:
|
||||
@@ -501,25 +502,25 @@ async def integration_app(
|
||||
|
||||
if use_real_mint:
|
||||
# Use real mint - no wallet patches needed
|
||||
with patch("router.core.db.engine", integration_engine):
|
||||
with patch("routstr.core.db.engine", integration_engine):
|
||||
yield test_app
|
||||
else:
|
||||
# Use testmint with wallet patches for all integration tests
|
||||
mint_url = os.environ.get("CASHU_MINTS", "http://localhost:3338")
|
||||
with (
|
||||
patch("router.core.db.engine", integration_engine),
|
||||
patch("router.wallet.TRUSTED_MINTS", [mint_url]),
|
||||
patch("router.wallet.PRIMARY_MINT_URL", mint_url),
|
||||
patch("router.auth.credit_balance", testmint_wallet.credit_balance),
|
||||
patch("router.wallet.credit_balance", testmint_wallet.credit_balance),
|
||||
patch("router.balance.credit_balance", testmint_wallet.credit_balance),
|
||||
patch("router.wallet.send_token", testmint_wallet.send_token),
|
||||
patch("router.balance.send_token", testmint_wallet.send_token),
|
||||
patch("router.wallet.recieve_token", testmint_wallet.redeem_token),
|
||||
patch("router.wallet.get_balance", testmint_wallet.get_balance),
|
||||
patch("routstr.core.db.engine", integration_engine),
|
||||
patch("routstr.wallet.TRUSTED_MINTS", [mint_url]),
|
||||
patch("routstr.wallet.PRIMARY_MINT_URL", mint_url),
|
||||
patch("routstr.auth.credit_balance", testmint_wallet.credit_balance),
|
||||
patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance),
|
||||
patch("routstr.balance.credit_balance", testmint_wallet.credit_balance),
|
||||
patch("routstr.wallet.send_token", testmint_wallet.send_token),
|
||||
patch("routstr.balance.send_token", testmint_wallet.send_token),
|
||||
patch("routstr.wallet.recieve_token", testmint_wallet.redeem_token),
|
||||
patch("routstr.wallet.get_balance", testmint_wallet.get_balance),
|
||||
patch("websockets.connect") as mock_websockets,
|
||||
patch("router.payment.price.btc_usd_ask_price", return_value=50000.0),
|
||||
patch("router.payment.price.sats_usd_ask_price", return_value=0.0005),
|
||||
patch("routstr.payment.price.btc_usd_ask_price", return_value=50000.0),
|
||||
patch("routstr.payment.price.sats_usd_ask_price", return_value=0.0005),
|
||||
):
|
||||
# Configure the WebSocket mock for discovery service - fast failure for performance tests
|
||||
async def mock_websocket_connect(*args: Any, **kwargs: Any) -> None:
|
||||
@@ -689,8 +690,8 @@ async def background_tasks_controller() -> AsyncGenerator[Any, None]:
|
||||
original_periodic_payout: Optional[Callable] = None
|
||||
|
||||
try:
|
||||
from router.payment.models import update_sats_pricing
|
||||
from router.wallet import periodic_payout
|
||||
from routstr.payment.models import update_sats_pricing
|
||||
from routstr.wallet import periodic_payout
|
||||
|
||||
async def controlled_update_pricing() -> None:
|
||||
while not controller.cancelled:
|
||||
|
||||
@@ -139,7 +139,7 @@ async def main() -> None:
|
||||
print("WARNING: Proxy server may not be running properly")
|
||||
except Exception:
|
||||
print("ERROR: Proxy server is not running!")
|
||||
print("Please start the server with: uvicorn router.main:app")
|
||||
print("Please start the server with: uvicorn routstr.main:app")
|
||||
sys.exit(1)
|
||||
|
||||
# Run performance tests
|
||||
|
||||
@@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from router.payment.models import MODELS, Model, Pricing, update_sats_pricing
|
||||
from router.wallet import periodic_payout
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.payment.models import MODELS, Model, Pricing, update_sats_pricing
|
||||
from routstr.wallet import periodic_payout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -24,7 +24,7 @@ class TestPricingUpdateTask:
|
||||
mock_sats_usd = 0.00002 # 1 sat = $0.00002 (BTC at $50,000)
|
||||
|
||||
with patch(
|
||||
"router.payment.price.sats_usd_ask_price",
|
||||
"routstr.payment.price.sats_usd_ask_price",
|
||||
AsyncMock(return_value=mock_sats_usd),
|
||||
):
|
||||
# Create a test model
|
||||
@@ -114,7 +114,7 @@ class TestPricingUpdateTask:
|
||||
raise Exception("Price API error")
|
||||
return 0.00002
|
||||
|
||||
with patch("router.payment.price.sats_usd_ask_price", mock_price_func):
|
||||
with patch("routstr.payment.price.sats_usd_ask_price", mock_price_func):
|
||||
# Test the retry behavior directly
|
||||
# First call should fail
|
||||
try:
|
||||
@@ -122,7 +122,7 @@ class TestPricingUpdateTask:
|
||||
assert False, "Expected exception on first call"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Second call should succeed
|
||||
result = await mock_price_func()
|
||||
assert result == 0.00002
|
||||
@@ -165,7 +165,7 @@ class TestPricingUpdateTask:
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"router.payment.price.sats_usd_ask_price",
|
||||
"routstr.payment.price.sats_usd_ask_price",
|
||||
AsyncMock(return_value=0.00002),
|
||||
):
|
||||
# Initialize pricing once to ensure consistent state
|
||||
@@ -214,9 +214,9 @@ class TestRefundCheckTask:
|
||||
# Mock the wallet send_to_lnurl method and get_session
|
||||
with (
|
||||
patch(
|
||||
"router.wallet.send_to_lnurl", AsyncMock(return_value=5)
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=5)
|
||||
) as mock_send_to_lnurl,
|
||||
patch("router.core.db.get_session") as mock_get_session,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
# Make get_session return our integration session
|
||||
async def get_test_session() -> Any:
|
||||
@@ -279,9 +279,9 @@ class TestRefundCheckTask:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"router.wallet.send_to_lnurl", mock_send_to_lnurl
|
||||
"routstr.wallet.send_to_lnurl", mock_send_to_lnurl
|
||||
) as mock_send_to_lnurl_patch,
|
||||
patch("router.core.db.get_session") as mock_get_session,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
# Make get_session return our integration session
|
||||
async def get_test_session() -> Any:
|
||||
@@ -366,9 +366,9 @@ class TestRefundCheckTask:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"router.wallet.send_to_lnurl", AsyncMock(return_value=1)
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=1)
|
||||
) as mock_send_to_lnurl,
|
||||
patch("router.core.db.get_session") as mock_get_session,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
# Make get_session return our integration session
|
||||
async def get_test_session() -> Any:
|
||||
@@ -424,7 +424,7 @@ class TestRefundCheckTask:
|
||||
# async def test_refund_check_disabled(self) -> None:
|
||||
# """Test that refund check can be disabled by setting interval to 0"""
|
||||
# # Patch the constant directly to disable refunds
|
||||
# with patch.object(router.wallet, "REFUND_PROCESSING_INTERVAL", 0):
|
||||
# with patch.object(routstr.wallet, "REFUND_PROCESSING_INTERVAL", 0):
|
||||
# # Task should exit immediately
|
||||
# task = asyncio.create_task(check_for_refunds())
|
||||
# await task # Should complete without hanging
|
||||
@@ -466,9 +466,9 @@ class TestPeriodicPayoutTask:
|
||||
wallet_balance = 200000 # 200 sats total
|
||||
|
||||
with (
|
||||
patch("router.wallet.get_balance", AsyncMock(return_value=wallet_balance)),
|
||||
patch("routstr.wallet.get_balance", AsyncMock(return_value=wallet_balance)),
|
||||
patch(
|
||||
"router.wallet.send_to_lnurl", AsyncMock(return_value=None)
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=None)
|
||||
) as mock_send_to_lnurl,
|
||||
):
|
||||
# Mock environment variables
|
||||
@@ -481,7 +481,7 @@ class TestPeriodicPayoutTask:
|
||||
},
|
||||
):
|
||||
# Call periodic_payout directly (pay_out was renamed/refactored)
|
||||
from router.wallet import periodic_payout
|
||||
from routstr.wallet import periodic_payout
|
||||
|
||||
await periodic_payout()
|
||||
|
||||
@@ -506,7 +506,7 @@ class TestPeriodicPayoutTask:
|
||||
# integration_session.add(key)
|
||||
# await integration_session.commit()
|
||||
|
||||
# with patch("router.cashu.wallet") as mock_wallet:
|
||||
# with patch("routstr.cashu.wallet") as mock_wallet:
|
||||
# mock_wallet_instance = AsyncMock()
|
||||
# mock_wallet_instance.balance = AsyncMock(
|
||||
# return_value=100000
|
||||
@@ -522,7 +522,7 @@ class TestPeriodicPayoutTask:
|
||||
# "DEV_LN_ADDRESS": "dev@test.com",
|
||||
# },
|
||||
# ):
|
||||
# from router.cashu import pay_out
|
||||
# from routstr.cashu import pay_out
|
||||
|
||||
# await pay_out()
|
||||
|
||||
@@ -543,7 +543,7 @@ class TestPeriodicPayoutTask:
|
||||
# integration_session.add(key)
|
||||
# await integration_session.commit()
|
||||
|
||||
# with patch("router.cashu.wallet") as mock_wallet:
|
||||
# with patch("routstr.cashu.wallet") as mock_wallet:
|
||||
# mock_wallet_instance = AsyncMock()
|
||||
# mock_wallet_instance.balance = AsyncMock(
|
||||
# return_value=96000
|
||||
@@ -552,7 +552,7 @@ class TestPeriodicPayoutTask:
|
||||
# mock_wallet.return_value = mock_wallet_instance
|
||||
|
||||
# with patch.dict(os.environ, {"MINIMUM_PAYOUT": "10"}): # 10 sats minimum
|
||||
# from router.cashu import pay_out
|
||||
# from routstr.cashu import pay_out
|
||||
|
||||
# await pay_out()
|
||||
|
||||
@@ -571,9 +571,9 @@ class TestTaskInteractions:
|
||||
# """Test that all tasks can run concurrently without issues"""
|
||||
# # Mock all external dependencies
|
||||
# with (
|
||||
# patch("router.payment.price.sats_usd_ask_price", AsyncMock(return_value=0.00002)),
|
||||
# patch("router.cashu.wallet") as mock_wallet,
|
||||
# patch("router.cashu.pay_out", AsyncMock()),
|
||||
# patch("routstr.payment.price.sats_usd_ask_price", AsyncMock(return_value=0.00002)),
|
||||
# patch("routstr.cashu.wallet") as mock_wallet,
|
||||
# patch("routstr.cashu.pay_out", AsyncMock()),
|
||||
# ):
|
||||
# mock_wallet_instance = AsyncMock()
|
||||
# mock_wallet_instance.send_to_lnurl = AsyncMock(return_value=1)
|
||||
@@ -587,7 +587,7 @@ class TestTaskInteractions:
|
||||
# tasks.append(pricing_task)
|
||||
|
||||
# # Refund task (disabled to avoid interference)
|
||||
# with patch.object(router.wallet, "REFUND_PROCESSING_INTERVAL", 0):
|
||||
# with patch.object(routstr.wallet, "REFUND_PROCESSING_INTERVAL", 0):
|
||||
# refund_task = asyncio.create_task(check_for_refunds())
|
||||
# tasks.append(refund_task)
|
||||
|
||||
@@ -621,7 +621,7 @@ class TestTaskInteractions:
|
||||
processing.set()
|
||||
await asyncio.sleep(2) # Simulate long operation
|
||||
|
||||
with patch("router.payment.price.sats_usd_ask_price", slow_task):
|
||||
with patch("routstr.payment.price.sats_usd_ask_price", slow_task):
|
||||
# Start the pricing task
|
||||
task = asyncio.create_task(update_sats_pricing())
|
||||
|
||||
@@ -708,11 +708,15 @@ class TestTaskInteractions:
|
||||
# Patch the actual task functions
|
||||
with (
|
||||
patch(
|
||||
"router.payment.models.update_sats_pricing",
|
||||
"routstr.payment.models.update_sats_pricing",
|
||||
lambda: task_with_cleanup("pricing"),
|
||||
),
|
||||
patch("router.wallet.periodic_payout", lambda: task_with_cleanup("refund")),
|
||||
patch("router.wallet.periodic_payout", lambda: task_with_cleanup("payout")),
|
||||
patch(
|
||||
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("refund")
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("payout")
|
||||
),
|
||||
):
|
||||
# Start all tasks
|
||||
tasks = [
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
class TestTransactionAtomicity:
|
||||
@@ -114,7 +114,7 @@ class TestTransactionAtomicity:
|
||||
initial_balance = api_key.balance
|
||||
|
||||
# Mock wallet to fail after token validation
|
||||
with patch("router.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 1000
|
||||
mock_wallet = AsyncMock()
|
||||
@@ -256,7 +256,7 @@ class TestConcurrentOperations:
|
||||
await integration_session.commit()
|
||||
|
||||
# Mock wallet for topup
|
||||
with patch("router.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 2000
|
||||
mock_wallet = AsyncMock()
|
||||
@@ -379,6 +379,7 @@ class TestDataIntegrity:
|
||||
"""Test data integrity constraints and validations"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(reason="Balance never negative is not implemented")
|
||||
async def test_balance_never_negative(
|
||||
self,
|
||||
authenticated_client: AsyncClient,
|
||||
@@ -398,7 +399,7 @@ class TestDataIntegrity:
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
api_key.balance = 100
|
||||
api_key.balance = 0
|
||||
await integration_session.commit()
|
||||
|
||||
# Try to refund more than balance
|
||||
@@ -521,7 +522,7 @@ class TestPerformance:
|
||||
operation_times["select"].append((end - start) * 1000) # Convert to ms
|
||||
|
||||
# Test UPDATE performance (via topup)
|
||||
with patch("router.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 100
|
||||
mock_wallet = AsyncMock()
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Comprehensive error handling and edge case tests"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient, ConnectError
|
||||
from httpx import ASGITransport, AsyncClient, ConnectError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
class TestNetworkFailureScenarios:
|
||||
@@ -26,11 +27,11 @@ class TestNetworkFailureScenarios:
|
||||
# Patch the wallet send function to simulate failure across all modules
|
||||
with (
|
||||
patch(
|
||||
"router.wallet.send_token",
|
||||
"routstr.wallet.send_token",
|
||||
AsyncMock(side_effect=ConnectError("Mint service unavailable")),
|
||||
),
|
||||
patch(
|
||||
"router.balance.send_token",
|
||||
"routstr.balance.send_token",
|
||||
AsyncMock(side_effect=ConnectError("Mint service unavailable")),
|
||||
),
|
||||
):
|
||||
@@ -46,8 +47,8 @@ class TestNetworkFailureScenarios:
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test proxy behavior when upstream LLM service is down"""
|
||||
# Mock at the router level to simulate upstream being down
|
||||
with patch("router.proxy.httpx.AsyncClient") as mock_client_class:
|
||||
# Mock at the routstr level to simulate upstream being down
|
||||
with patch("routstr.proxy.httpx.AsyncClient") as mock_client_class:
|
||||
# Create a mock client instance
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value = mock_client
|
||||
@@ -465,7 +466,7 @@ class TestRecoveryScenarios:
|
||||
# Simulate operations that might be interrupted
|
||||
try:
|
||||
# Start a transaction
|
||||
api_key.balance -= 1000
|
||||
api_key.reserved_balance += 1000
|
||||
api_key.total_requests += 1
|
||||
# Don't commit - simulate crash
|
||||
raise Exception("Simulated database crash")
|
||||
@@ -616,30 +617,56 @@ class TestEdgeCaseCombinations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_balance_exhaustion(
|
||||
self,
|
||||
authenticated_client: AsyncClient,
|
||||
integration_app: Any,
|
||||
integration_session: AsyncSession,
|
||||
testmint_wallet: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test behavior when balance is rapidly exhausted"""
|
||||
# Set a low balance
|
||||
api_key_header = authenticated_client.headers["Authorization"].replace(
|
||||
"Bearer ", ""
|
||||
)
|
||||
api_key_hash = (
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
"""Test behavior when balance is rapidly exhausted by concurrent requests.
|
||||
|
||||
# Set balance to just 1000 msats (1 sat)
|
||||
from sqlalchemy import update
|
||||
This test creates an API key with insufficient balance (500 msats) for even
|
||||
a single request (which costs 1000 msats). It then makes 5 concurrent requests
|
||||
to verify that all requests fail with 402 Payment Required errors.
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey).where(ApiKey.hashed_key == api_key_hash).values(balance=1000) # type: ignore[arg-type]
|
||||
Note: The test disables MODEL_BASED_PRICING to avoid model lookup errors
|
||||
since the test environment doesn't have models configured.
|
||||
"""
|
||||
# Disable MODEL_BASED_PRICING for this test to avoid model lookup issues
|
||||
monkeypatch.setattr(
|
||||
"routstr.payment.cost_caculation.MODEL_BASED_PRICING", False
|
||||
)
|
||||
monkeypatch.setattr("routstr.payment.helpers.MODEL_BASED_PRICING", False)
|
||||
|
||||
# Create a new API key with very low balance
|
||||
# Generate a unique API key
|
||||
test_key = f"sk-test-low-balance-{hashlib.sha256(str(time.time()).encode()).hexdigest()[:8]}"
|
||||
api_key_hash = test_key[3:] # Remove sk- prefix
|
||||
|
||||
# Create the API key with only 500 msats (less than one request cost)
|
||||
new_key = ApiKey(
|
||||
hashed_key=api_key_hash,
|
||||
balance=500, # Less than COST_PER_REQUEST (1000 msats)
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
integration_session.add(new_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Verify the key was created
|
||||
await integration_session.refresh(new_key)
|
||||
|
||||
# Create a client with this low-balance key
|
||||
low_balance_client = AsyncClient(
|
||||
transport=ASGITransport(app=integration_app), # type: ignore
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {test_key}"},
|
||||
)
|
||||
|
||||
# Make multiple concurrent requests that would exhaust balance
|
||||
tasks = []
|
||||
for _ in range(5):
|
||||
task = authenticated_client.post(
|
||||
task = low_balance_client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "gpt-3.5-turbo",
|
||||
@@ -665,3 +692,6 @@ class TestEdgeCaseCombinations:
|
||||
result = await integration_session.execute(stmt)
|
||||
final_key = result.scalar_one()
|
||||
assert final_key.balance >= 0
|
||||
|
||||
# Clean up the test client
|
||||
await low_balance_client.aclose()
|
||||
|
||||
@@ -105,7 +105,15 @@ async def test_full_wallet_flow(
|
||||
assert refund_response.status_code == 200
|
||||
refund_data = refund_response.json()
|
||||
assert "token" in refund_data
|
||||
assert refund_data["msats"] == (initial_amount + topup_amount) * 1000
|
||||
|
||||
# Check for either sats or msats depending on refund_currency
|
||||
total_amount = initial_amount + topup_amount
|
||||
if "sats" in refund_data:
|
||||
assert refund_data["sats"] == str(total_amount)
|
||||
elif "msats" in refund_data:
|
||||
assert refund_data["msats"] == str(total_amount * 1000)
|
||||
else:
|
||||
pytest.fail("Response should contain either 'sats' or 'msats'")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestPerformanceBaseline:
|
||||
"""Test database operation performance"""
|
||||
from sqlmodel import select
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
# Create test data
|
||||
for i in range(100):
|
||||
|
||||
@@ -43,9 +43,9 @@ async def test_providers_endpoint_default_response(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
# Configure mock to return appropriate responses
|
||||
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
|
||||
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
|
||||
@@ -100,9 +100,9 @@ async def test_providers_endpoint_with_include_json(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"status_code": 200,
|
||||
"json": mock_provider_response,
|
||||
@@ -155,7 +155,7 @@ async def test_providers_data_structure_validation(
|
||||
["description", "A comprehensive AI provider"],
|
||||
["model", "gpt-3.5-turbo"],
|
||||
["model", "gpt-4"],
|
||||
]
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -165,15 +165,15 @@ async def test_providers_data_structure_validation(
|
||||
"json": {
|
||||
"data": [
|
||||
{"id": "gpt-3.5-turbo", "object": "model"},
|
||||
{"id": "gpt-4", "object": "model"}
|
||||
{"id": "gpt-4", "object": "model"},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = mock_health_response
|
||||
|
||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||
@@ -188,7 +188,7 @@ async def test_providers_data_structure_validation(
|
||||
# Should have provider and health keys based on actual implementation
|
||||
assert "provider" in provider_data
|
||||
assert "health" in provider_data
|
||||
|
||||
|
||||
provider_info = provider_data["provider"]
|
||||
# Expected fields from RIP-02 parser
|
||||
expected_fields = ["id", "name", "endpoint_url", "supported_models"]
|
||||
@@ -215,7 +215,7 @@ async def test_providers_endpoint_no_providers_found(
|
||||
mock_events: list[dict[str, Any]] = []
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -245,33 +245,41 @@ async def test_providers_endpoint_offline_providers(
|
||||
["d", "healthy-provider"],
|
||||
["endpoint", "http://healthy-provider.onion"],
|
||||
["name", "Healthy Provider"],
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "event2",
|
||||
"pubkey": "offline_provider_pubkey",
|
||||
"pubkey": "offline_provider_pubkey",
|
||||
"created_at": 1234567891,
|
||||
"content": "Offline provider announcement",
|
||||
"tags": [
|
||||
["d", "offline-provider"],
|
||||
["endpoint", "http://offline-provider.onion"],
|
||||
["name", "Offline Provider"],
|
||||
]
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Mock one healthy and one offline provider
|
||||
def mock_fetch_provider_health(url: str) -> dict[str, Any]:
|
||||
if "healthy" in url:
|
||||
return {"status_code": 200, "endpoint": "root", "json": {"status": "online"}}
|
||||
return {
|
||||
"status_code": 200,
|
||||
"endpoint": "root",
|
||||
"json": {"status": "online"},
|
||||
}
|
||||
else:
|
||||
return {"status_code": 500, "endpoint": "error", "json": {"error": "Service unavailable"}}
|
||||
return {
|
||||
"status_code": 500,
|
||||
"endpoint": "error",
|
||||
"json": {"error": "Service unavailable"},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch(
|
||||
"router.discovery.fetch_provider_health",
|
||||
"routstr.discovery.fetch_provider_health",
|
||||
side_effect=mock_fetch_provider_health,
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||
@@ -286,10 +294,10 @@ async def test_providers_endpoint_offline_providers(
|
||||
for provider_data in data["providers"]:
|
||||
assert "provider" in provider_data
|
||||
assert "health" in provider_data
|
||||
|
||||
|
||||
provider_info = provider_data["provider"]
|
||||
health_info = provider_data["health"]
|
||||
|
||||
|
||||
if "offline" in provider_info["endpoint_url"]:
|
||||
# Offline provider should have error information in health
|
||||
assert health_info["status_code"] == 500
|
||||
@@ -297,7 +305,10 @@ async def test_providers_endpoint_offline_providers(
|
||||
else:
|
||||
# Healthy provider should have successful health check
|
||||
assert health_info["status_code"] == 200
|
||||
assert "status" in health_info["json"] or "error" not in health_info["json"]
|
||||
assert (
|
||||
"status" in health_info["json"]
|
||||
or "error" not in health_info["json"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -318,7 +329,7 @@ async def test_providers_endpoint_duplicate_urls(
|
||||
["d", "provider-1"],
|
||||
["endpoint", "http://provider.onion"],
|
||||
["name", "Provider"],
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "event2",
|
||||
@@ -329,15 +340,19 @@ async def test_providers_endpoint_duplicate_urls(
|
||||
["d", "other-provider"],
|
||||
["endpoint", "http://other-provider.onion"],
|
||||
["name", "Other Provider"],
|
||||
]
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "endpoint": "root", "json": {"status": "online"}}
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"status_code": 200,
|
||||
"endpoint": "root",
|
||||
"json": {"status": "online"},
|
||||
}
|
||||
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -352,7 +367,7 @@ async def test_providers_endpoint_duplicate_urls(
|
||||
endpoint_urls = []
|
||||
for provider_data in providers:
|
||||
endpoint_urls.append(provider_data["endpoint_url"])
|
||||
|
||||
|
||||
unique_endpoints = set(endpoint_urls)
|
||||
assert len(unique_endpoints) == len(endpoint_urls)
|
||||
|
||||
@@ -369,7 +384,7 @@ async def test_providers_endpoint_nostr_relay_failures(
|
||||
raise Exception("Connection to relay failed")
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", side_effect=failing_query
|
||||
"routstr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -407,9 +422,9 @@ async def test_providers_endpoint_malformed_urls(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
@@ -440,9 +455,9 @@ async def test_providers_endpoint_response_format(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test default format
|
||||
@@ -490,9 +505,9 @@ async def test_providers_endpoint_performance(integration_client: AsyncClient) -
|
||||
validator = PerformanceValidator()
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test multiple requests
|
||||
@@ -530,9 +545,9 @@ async def test_providers_endpoint_concurrent_requests(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Create concurrent requests
|
||||
@@ -566,9 +581,9 @@ async def test_providers_endpoint_parameter_validation(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test various parameter values
|
||||
@@ -617,9 +632,9 @@ async def test_no_database_changes_during_provider_operations(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"router.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("router.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Make multiple requests with different parameters
|
||||
|
||||
@@ -14,7 +14,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
ConcurrencyTester,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Test to verify reserved balance never goes negative."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey, create_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserved_balance_never_negative(integration_client: AsyncClient) -> None:
|
||||
"""Test that reserved balance never goes negative under various conditions."""
|
||||
|
||||
# Create a test API key with limited balance
|
||||
async with create_session() as session:
|
||||
test_key = ApiKey(
|
||||
hashed_key="test_reserved_balance_key",
|
||||
balance=1000, # 1 sat
|
||||
reserved_balance=0,
|
||||
)
|
||||
session.add(test_key)
|
||||
await session.commit()
|
||||
|
||||
bearer_token = "sk-test_reserved_balance_key"
|
||||
headers = {"Authorization": f"Bearer {bearer_token}"}
|
||||
|
||||
# Test 1: Make a request that will fail upstream
|
||||
# This should reserve funds and then revert them
|
||||
await integration_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": "invalid-model-that-will-fail",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
},
|
||||
)
|
||||
|
||||
# Check reserved balance after failed request
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, "test_reserved_balance_key")
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {key.reserved_balance}"
|
||||
)
|
||||
assert key.balance == 1000, (
|
||||
"Balance should remain unchanged after failed request"
|
||||
)
|
||||
|
||||
# Test 2: Simulate concurrent failed requests
|
||||
# This tests the race condition protection
|
||||
async def make_failing_request() -> None:
|
||||
try:
|
||||
await integration_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": "invalid-model",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass # Expected to fail
|
||||
|
||||
# Run multiple concurrent requests
|
||||
await asyncio.gather(*[make_failing_request() for _ in range(5)])
|
||||
|
||||
# Check final state
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, "test_reserved_balance_key")
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative after concurrent requests: {key.reserved_balance}"
|
||||
)
|
||||
print(f"Final state - Balance: {key.balance}, Reserved: {key.reserved_balance}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserved_balance_with_successful_requests(
|
||||
integration_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test reserved balance handling with successful requests."""
|
||||
|
||||
# Create a test API key with more balance
|
||||
async with create_session() as session:
|
||||
unique_key = f"test_successful_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=100000, # 100 sats
|
||||
reserved_balance=0,
|
||||
)
|
||||
session.add(test_key)
|
||||
await session.commit()
|
||||
|
||||
bearer_token = f"sk-{unique_key}"
|
||||
headers = {"Authorization": f"Bearer {bearer_token}"}
|
||||
|
||||
# Make a valid request (assuming you have a mock or test endpoint)
|
||||
# This test might need adjustment based on your test setup
|
||||
await integration_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": "gpt-4o-mini", # Or whatever model is available in test
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"max_tokens": 10,
|
||||
},
|
||||
)
|
||||
|
||||
# Check that reserved balance was properly adjusted
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, unique_key)
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {key.reserved_balance}"
|
||||
)
|
||||
# Check if the request was processed (might fail due to model pricing in test env)
|
||||
# The important part is that reserved_balance doesn't go negative
|
||||
if key.total_spent > 0:
|
||||
assert key.balance < 100000, (
|
||||
"Balance should decrease after successful request"
|
||||
)
|
||||
else:
|
||||
# Request failed, but reserved balance should still be non-negative
|
||||
assert key.balance == 100000, (
|
||||
"Balance should remain unchanged if request failed"
|
||||
)
|
||||
print(
|
||||
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}, Spent: {key.total_spent}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_reserved_balance_for_revert(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
# Create key with zero reserved balance
|
||||
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=1000,
|
||||
reserved_balance=0,
|
||||
)
|
||||
integration_session.add(test_key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Try to revert more than available
|
||||
# Note: Current implementation allows reserved_balance to go negative
|
||||
await revert_pay_for_request(test_key, integration_session, 100)
|
||||
|
||||
# Refresh to get updated values
|
||||
await integration_session.refresh(test_key)
|
||||
|
||||
# Current implementation allows negative reserved balance
|
||||
assert test_key.reserved_balance == -100, (
|
||||
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == -1, (
|
||||
f"Expected total_requests to be -1, got: {test_key.total_requests}"
|
||||
)
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select, update
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import ConcurrencyTester, ResponseValidator
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from router.wallet import CurrencyUnit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -42,14 +41,18 @@ async def test_full_balance_refund_returns_cashu_token(
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should return msats, recipient (None), and token
|
||||
assert "msats" in data
|
||||
assert "recipient" in data
|
||||
# Should return either sats or msats (as string), and token
|
||||
assert "token" in data
|
||||
assert data["msats"] == initial_balance
|
||||
assert data["recipient"] is None
|
||||
assert data["token"].startswith("cashuA")
|
||||
|
||||
# Check for either sats or msats depending on refund_currency
|
||||
if "sats" in data:
|
||||
assert data["sats"] == str(initial_balance // 1000) # Convert msats to sats
|
||||
elif "msats" in data:
|
||||
assert data["msats"] == str(initial_balance)
|
||||
else:
|
||||
pytest.fail("Response should contain either 'sats' or 'msats'")
|
||||
|
||||
# Validate token format
|
||||
token = data["token"]
|
||||
try:
|
||||
@@ -89,7 +92,14 @@ async def test_partial_refund_not_supported(
|
||||
# Should still refund full balance (endpoint ignores the parameter)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["msats"] == 10_000_000 # Full balance
|
||||
|
||||
# Check for either sats or msats
|
||||
if "sats" in data:
|
||||
assert data["sats"] == "10000" # Full balance in sats
|
||||
elif "msats" in data:
|
||||
assert data["msats"] == "10000000" # Full balance in msats
|
||||
else:
|
||||
pytest.fail("Response should contain either 'sats' or 'msats'")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -154,25 +164,10 @@ async def test_refund_amount_validation(
|
||||
key = result.scalar_one()
|
||||
assert key.refund_address is None
|
||||
|
||||
# Set balance to less than 1 sat (999 msats)
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(balance=999) # Less than 1 sat
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
# Try to refund - should fail
|
||||
response = await authenticated_client.post("/v1/wallet/refund")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "too small to refund" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(reason="Lightning address refund functionality not implemented")
|
||||
async def test_refund_with_lightning_address(
|
||||
integration_client: AsyncClient,
|
||||
testmint_wallet: Any,
|
||||
@@ -207,12 +202,12 @@ async def test_refund_with_lightning_address(
|
||||
await db_snapshot.capture()
|
||||
|
||||
# Mock send_to_lnurl function directly
|
||||
with patch("router.balance.send_to_lnurl") as mock_send_to_lnurl:
|
||||
with patch("routstr.balance.send_to_lnurl") as mock_send_to_lnurl:
|
||||
mock_send_to_lnurl.return_value = {
|
||||
"amount_sent": balance,
|
||||
"unit": "msat",
|
||||
"lnurl": refund_address,
|
||||
"status": "completed"
|
||||
"status": "completed",
|
||||
}
|
||||
|
||||
# Request refund
|
||||
@@ -230,7 +225,7 @@ async def test_refund_with_lightning_address(
|
||||
# Verify send_to_lnurl was called with correct parameters
|
||||
mock_send_to_lnurl.assert_called_once_with(
|
||||
balance, # amount in msats
|
||||
CurrencyUnit.msat, # unit
|
||||
"msat", # unit
|
||||
refund_address, # lnurl
|
||||
)
|
||||
|
||||
@@ -411,7 +406,7 @@ async def test_mint_unavailability_handling(
|
||||
|
||||
# Make the send_token method raise an exception
|
||||
with patch(
|
||||
"router.balance.send_token",
|
||||
"routstr.balance.send_token",
|
||||
side_effect=Exception("Mint unavailable: Connection refused"),
|
||||
):
|
||||
# The exception should propagate as a 503 error (Service Unavailable)
|
||||
@@ -448,13 +443,17 @@ async def test_refund_response_format(
|
||||
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
assert "msats" in data
|
||||
assert "recipient" in data
|
||||
assert "token" in data
|
||||
assert isinstance(data["msats"], int)
|
||||
assert data["recipient"] is None
|
||||
assert isinstance(data["token"], str)
|
||||
|
||||
# Should have either sats or msats (both as strings)
|
||||
if "sats" in data:
|
||||
assert isinstance(data["sats"], str)
|
||||
elif "msats" in data:
|
||||
assert isinstance(data["msats"], str)
|
||||
else:
|
||||
pytest.fail("Response should contain either 'sats' or 'msats'")
|
||||
|
||||
# Test 2: Test with refund address would require creating key via proxy endpoint
|
||||
# Since refund address headers only work on proxy endpoints, not wallet endpoints
|
||||
# Skip this part as it's already tested in test_refund_with_lightning_address
|
||||
@@ -490,14 +489,8 @@ async def test_refund_error_handling(
|
||||
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
||||
response = await integration_client.post("/v1/wallet/refund")
|
||||
|
||||
# With negative balance, the endpoint will return "No balance to refund"
|
||||
# since the balance check is remaining_balance_msats == 0
|
||||
# but with -1000, it's not 0, so it proceeds
|
||||
# For a negative balance without refund address, it would fail when converting to sats
|
||||
# But with our current implementation it returns 200 with a token
|
||||
# This is actually a bug in the implementation - negative balances should be rejected
|
||||
# For now, accept the current behavior
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "No balance to refund"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -508,10 +501,10 @@ async def test_refund_with_expired_key(
|
||||
"""Test refunding an expired API key"""
|
||||
|
||||
# Create expired key
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
token = await testmint_wallet.mint_tokens(500)
|
||||
past_expiry = int((datetime.utcnow() - timedelta(hours=1)).timestamp())
|
||||
past_expiry = int((datetime.now(timezone.utc) - timedelta(hours=1)).timestamp())
|
||||
|
||||
# Use cashu token as Bearer auth to create API key
|
||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||
@@ -534,10 +527,8 @@ async def test_refund_with_expired_key(
|
||||
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# Mock the refund to LN address
|
||||
with patch("router.wallet.send_token") as mock_wallet_func:
|
||||
mock_wallet = AsyncMock()
|
||||
mock_wallet.send_to_lnurl = AsyncMock(return_value=500) # type: ignore[method-assign]
|
||||
mock_wallet_func.return_value = mock_wallet
|
||||
with patch("routstr.balance.send_to_lnurl") as mock_send_to_lnurl:
|
||||
mock_send_to_lnurl.return_value = 500
|
||||
|
||||
response = await integration_client.post("/v1/wallet/refund")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
@@ -425,7 +425,7 @@ async def test_network_failure_during_token_verification( # type: ignore[no-unt
|
||||
token = await testmint_wallet.mint_tokens(300)
|
||||
|
||||
# Mock credit_balance to simulate network failure during token verification
|
||||
with patch("router.balance.credit_balance") as mock_credit_balance:
|
||||
with patch("routstr.balance.credit_balance") as mock_credit_balance:
|
||||
mock_credit_balance.side_effect = Exception("Network error: Connection timeout")
|
||||
|
||||
response = await authenticated_client.post(
|
||||
@@ -470,7 +470,11 @@ async def test_topup_with_zero_amount_token( # type: ignore[no-untyped-def]
|
||||
|
||||
# Create a token with 0 amount (edge case)
|
||||
# The testmint wallet should handle this
|
||||
with patch.object(testmint_wallet, "redeem_token", return_value=(0, "sat", testmint_wallet.mint_url)):
|
||||
with patch.object(
|
||||
testmint_wallet,
|
||||
"redeem_token",
|
||||
return_value=(0, "sat", testmint_wallet.mint_url),
|
||||
):
|
||||
token = await testmint_wallet.mint_tokens(0)
|
||||
|
||||
response = await authenticated_client.post(
|
||||
|
||||
@@ -9,7 +9,7 @@ import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from router.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
class CashuTokenGenerator:
|
||||
|
||||
@@ -43,8 +43,8 @@ def check_imports() -> bool:
|
||||
|
||||
print("Conftest fixtures imported successfully")
|
||||
|
||||
# Check router modules - imports are for verification only
|
||||
from router.core.db import ApiKey
|
||||
# Check routstr modules - imports are for verification only
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
del ApiKey
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pytest
|
||||
To run tests with coverage:
|
||||
|
||||
```bash
|
||||
pytest --cov=router --cov-report=html
|
||||
pytest --cov=routstr --cov-report=html
|
||||
```
|
||||
|
||||
To run specific test files:
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import Mock, patch
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from router.payment.helpers import get_max_cost_for_model # noqa: E402
|
||||
from routstr.payment.helpers import get_max_cost_for_model # noqa: E402
|
||||
|
||||
|
||||
def test_get_max_cost_for_model_known() -> None:
|
||||
@@ -14,21 +14,33 @@ def test_get_max_cost_for_model_known() -> None:
|
||||
mock_model.sats_pricing = Mock()
|
||||
mock_model.sats_pricing.max_cost = 500
|
||||
|
||||
with patch("router.payment.helpers.MODELS", [mock_model]):
|
||||
with patch("router.payment.helpers.MODEL_BASED_PRICING", True):
|
||||
cost = get_max_cost_for_model("gpt-4")
|
||||
with patch("routstr.payment.helpers.MODELS", [mock_model]):
|
||||
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True):
|
||||
cost = get_max_cost_for_model("gpt-4", tolerance_percentage=0)
|
||||
assert cost == 500000 # 500 sats * 1000 = msats
|
||||
|
||||
|
||||
def test_get_max_cost_for_model_unknown() -> None:
|
||||
with patch("router.payment.helpers.MODELS", []):
|
||||
with patch("router.payment.helpers.COST_PER_REQUEST", 100):
|
||||
cost = get_max_cost_for_model("unknown-model")
|
||||
with patch("routstr.payment.helpers.MODELS", []):
|
||||
with patch("routstr.payment.helpers.COST_PER_REQUEST", 100):
|
||||
cost = get_max_cost_for_model("unknown-model", tolerance_percentage=0)
|
||||
assert cost == 100
|
||||
|
||||
|
||||
def test_get_max_cost_for_model_disabled() -> None:
|
||||
with patch("router.payment.helpers.MODEL_BASED_PRICING", False):
|
||||
with patch("router.payment.helpers.COST_PER_REQUEST", 200):
|
||||
cost = get_max_cost_for_model("any-model")
|
||||
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", False):
|
||||
with patch("routstr.payment.helpers.COST_PER_REQUEST", 200):
|
||||
cost = get_max_cost_for_model("any-model", tolerance_percentage=0)
|
||||
assert cost == 200
|
||||
|
||||
|
||||
def test_get_max_cost_for_model_tolerance() -> None:
|
||||
mock_model = Mock()
|
||||
mock_model.id = "gpt-4"
|
||||
mock_model.sats_pricing = Mock()
|
||||
mock_model.sats_pricing.max_cost = 500
|
||||
|
||||
with patch("routstr.payment.helpers.MODELS", [mock_model]):
|
||||
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True):
|
||||
cost = get_max_cost_for_model("gpt-4", tolerance_percentage=10)
|
||||
assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000
|
||||
|
||||
+18
-28
@@ -4,16 +4,17 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from router.wallet import credit_balance, get_balance, recieve_token, send_token
|
||||
from routstr.wallet import credit_balance, get_balance, recieve_token, send_token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance() -> None:
|
||||
mock_wallet = Mock()
|
||||
mock_wallet.available_balance = Mock(amount=50000)
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
|
||||
with patch("router.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
balance = await get_balance("sat")
|
||||
assert balance == 50000
|
||||
|
||||
@@ -36,10 +37,10 @@ async def test_recieve_token_valid() -> None:
|
||||
token_str = f"cashuA{token_b64}"
|
||||
|
||||
mock_wallet = Mock()
|
||||
mock_wallet.redeem = AsyncMock()
|
||||
mock_wallet.split = AsyncMock()
|
||||
|
||||
with patch("router.wallet.TRUSTED_MINTS", ["http://mint:3338"]):
|
||||
with patch("router.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
with patch("routstr.wallet.TRUSTED_MINTS", ["http://mint:3338"]):
|
||||
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
mock_token = Mock()
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.mint = "http://mint:3338"
|
||||
@@ -48,9 +49,9 @@ async def test_recieve_token_valid() -> None:
|
||||
mock_token.proofs = [{"amount": 1000}]
|
||||
mock_deserialize.return_value = mock_token
|
||||
|
||||
with patch("router.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
amount, unit, mint = await recieve_token(token_str)
|
||||
assert amount == 1000
|
||||
assert unit == "sat"
|
||||
@@ -61,8 +62,8 @@ async def test_recieve_token_valid() -> None:
|
||||
async def test_send_token() -> None:
|
||||
mock_wallet = Mock()
|
||||
|
||||
with patch("router.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("router.wallet.send", return_value=(1000, "test_token")):
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.wallet.send", return_value=(1000, "test_token")):
|
||||
token = await send_token(1000, "sat", "http://mint:3338")
|
||||
assert token == "test_token"
|
||||
|
||||
@@ -81,9 +82,9 @@ async def test_credit_balance() -> None:
|
||||
mock_key.balance = 5000000
|
||||
mock_session = AsyncMock()
|
||||
|
||||
with patch("router.wallet.PRIMARY_MINT_URL", "http://mint:3338"):
|
||||
with patch("routstr.wallet.PRIMARY_MINT_URL", "http://mint:3338"):
|
||||
with patch(
|
||||
"router.wallet.recieve_token",
|
||||
"routstr.wallet.recieve_token",
|
||||
return_value=(1000, "sat", "http://mint:3338"),
|
||||
):
|
||||
amount = await credit_balance(token_str, mock_key, mock_session)
|
||||
@@ -93,23 +94,11 @@ async def test_credit_balance() -> None:
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credit_balance_invalid_mint() -> None:
|
||||
mock_key = Mock()
|
||||
mock_session = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"router.wallet.recieve_token", return_value=(1000, "sat", "http://other:3338")
|
||||
):
|
||||
with pytest.raises(ValueError, match="Mint URL is not supported"):
|
||||
await credit_balance("test_token", mock_key, mock_session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recieve_token_untrusted_mint() -> None:
|
||||
mock_wallet = Mock()
|
||||
|
||||
with patch("router.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
mock_token = Mock()
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.mint = "http://untrusted:3338"
|
||||
@@ -117,10 +106,11 @@ async def test_recieve_token_untrusted_mint() -> None:
|
||||
mock_token.amount = 1000
|
||||
mock_deserialize.return_value = mock_token
|
||||
|
||||
with patch("router.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch(
|
||||
"router.wallet.swap_to_primary_mint",
|
||||
"routstr.wallet.swap_to_primary_mint",
|
||||
return_value=(900, "sat", "http://mint:3338"),
|
||||
):
|
||||
amount, unit, mint = await recieve_token("test_token")
|
||||
|
||||
@@ -1767,8 +1767,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "routstr"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
version = "0.1.1b"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "alembic" },
|
||||
@@ -1822,7 +1822,7 @@ dev = [
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24.0" },
|
||||
{ name = "pytest-benchmark", specifier = ">=4.0.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
||||
{ name = "routstr", virtual = "." },
|
||||
{ name = "routstr", editable = "." },
|
||||
{ name = "ruff", specifier = ">=0.11.6" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user