mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 15:26:14 +00:00
957 lines
34 KiB
Python
957 lines
34 KiB
Python
import asyncio
|
|
import hashlib
|
|
import os
|
|
import pathlib
|
|
import sqlite3
|
|
import time
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from enum import Enum
|
|
from typing import AsyncGenerator
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from alembic.util.exc import CommandError
|
|
from sqlalchemy import Index, UniqueConstraint, case, delete, event, or_
|
|
from sqlalchemy.engine import make_url
|
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
|
from sqlalchemy.orm import aliased
|
|
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from .logging import get_logger
|
|
from .settings import settings
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
|
|
|
|
|
|
def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine:
|
|
"""Build and instrument an async engine from environment-only settings."""
|
|
url = make_url(database_url)
|
|
backend = url.get_backend_name()
|
|
is_sqlite = backend == "sqlite"
|
|
is_memory_sqlite = is_sqlite and url.database in {None, "", ":memory:"}
|
|
pool_pre_ping = settings.database_pool_pre_ping or not is_sqlite
|
|
options: dict[str, int | float | bool] = {"pool_pre_ping": pool_pre_ping}
|
|
if not is_memory_sqlite:
|
|
options.update(
|
|
pool_size=settings.database_pool_size,
|
|
max_overflow=settings.database_max_overflow,
|
|
pool_timeout=settings.database_pool_timeout,
|
|
pool_recycle=settings.database_pool_recycle,
|
|
)
|
|
|
|
logger.info(
|
|
"Database pool configured",
|
|
extra={
|
|
"database_url_backend": backend,
|
|
"in_memory_sqlite": is_memory_sqlite,
|
|
**options,
|
|
},
|
|
)
|
|
created_engine = create_async_engine(database_url, echo=False, **options)
|
|
hold_warn_seconds = settings.database_pool_hold_warn_seconds
|
|
|
|
def record_pool_checkout(
|
|
dbapi_connection: object, connection_record: object, proxy: object
|
|
) -> None:
|
|
connection_record.info["routstr_checked_out_at"] = time.monotonic() # type: ignore[attr-defined]
|
|
|
|
def record_pool_checkin(
|
|
dbapi_connection: object, connection_record: object
|
|
) -> None:
|
|
checked_out_at = connection_record.info.pop( # type: ignore[attr-defined]
|
|
"routstr_checked_out_at", None
|
|
)
|
|
if checked_out_at is None:
|
|
return
|
|
held_seconds = time.monotonic() - checked_out_at
|
|
if held_seconds >= hold_warn_seconds:
|
|
logger.warning(
|
|
"Database connection held longer than threshold",
|
|
extra={
|
|
"held_seconds": round(held_seconds, 3),
|
|
"threshold_seconds": hold_warn_seconds,
|
|
"pool_status": created_engine.pool.status(),
|
|
},
|
|
)
|
|
|
|
event.listen(created_engine.sync_engine, "checkout", record_pool_checkout)
|
|
event.listen(created_engine.sync_engine, "checkin", record_pool_checkin)
|
|
return created_engine
|
|
|
|
|
|
engine = create_db_engine()
|
|
|
|
|
|
class ApiKey(SQLModel, table=True): # type: ignore
|
|
__tablename__ = "api_keys"
|
|
|
|
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)"
|
|
)
|
|
reserved_at: int | None = Field(
|
|
default=None,
|
|
description=(
|
|
"Unix timestamp of the most recent balance reservation. Used to "
|
|
"detect and release stale reservations (e.g. after client "
|
|
"disconnects). NULL when no reservation has been made yet."
|
|
),
|
|
)
|
|
refund_address: str | None = Field(
|
|
default=None,
|
|
description="Lightning address to refund remaining balance after key expires",
|
|
)
|
|
key_expiry_time: int | None = Field(
|
|
default=None,
|
|
description="Unix-timestamp after which the cashu-token's balance gets refunded to the refund_address",
|
|
)
|
|
total_spent: int = Field(
|
|
default=0, description="Total spent in millisatoshis (msats)"
|
|
)
|
|
total_requests: int = Field(default=0)
|
|
created_at: int | None = Field(
|
|
default_factory=lambda: int(time.time()),
|
|
nullable=True,
|
|
description=(
|
|
"Unix timestamp when the key was created. Nullable: keys created "
|
|
"before this column existed have no value and sort last."
|
|
),
|
|
)
|
|
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",
|
|
)
|
|
parent_key_hash: str | None = Field(
|
|
default=None, foreign_key="api_keys.hashed_key", index=True
|
|
)
|
|
balance_limit: int | None = Field(
|
|
default=None,
|
|
description="Max spendable balance in msats for this key (mostly for child keys)",
|
|
)
|
|
balance_limit_reset: str | None = Field(
|
|
default=None,
|
|
description="Reset policy for balance limit (manual, daily, monthly, etc.)",
|
|
)
|
|
balance_limit_reset_date: int | None = Field(
|
|
default=None,
|
|
description="Unix timestamp of the last time the balance limit was reset",
|
|
)
|
|
validity_date: int | None = Field(
|
|
default=None,
|
|
description="Unix timestamp after which the key is no longer valid",
|
|
)
|
|
|
|
@property
|
|
def total_balance(self) -> int:
|
|
return self.balance - self.reserved_balance
|
|
|
|
|
|
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
|
"""Release every active durable reservation during explicit startup reset."""
|
|
await session.exec( # type: ignore[call-overload]
|
|
update(ReservationRelease)
|
|
.where(col(ReservationRelease.status) == "active")
|
|
.values(status="released")
|
|
)
|
|
await session.exec( # type: ignore[call-overload]
|
|
update(ApiKey).values(reserved_balance=0, reserved_at=None)
|
|
)
|
|
await session.commit()
|
|
logger.info("Reset reserved balances on startup")
|
|
|
|
|
|
async def release_stale_reservations(
|
|
session: AsyncSession,
|
|
max_age_seconds: int,
|
|
*,
|
|
key_hash: str | None = None,
|
|
) -> int:
|
|
"""Release stale durable reservations without touching newer reservations."""
|
|
cutoff = int(time.time()) - max_age_seconds
|
|
query = (
|
|
select(ReservationRelease)
|
|
.where(col(ReservationRelease.status) == "active")
|
|
.where(col(ReservationRelease.created_at) < cutoff)
|
|
)
|
|
if key_hash is not None:
|
|
query = query.where(
|
|
or_(
|
|
col(ReservationRelease.key_hash) == key_hash,
|
|
col(ReservationRelease.billing_key_hash) == key_hash,
|
|
)
|
|
)
|
|
reservations = (await session.exec(query)).all()
|
|
released = 0
|
|
|
|
for reservation in reservations:
|
|
transition = await session.exec( # type: ignore[call-overload]
|
|
update(ReservationRelease)
|
|
.where(col(ReservationRelease.id) == reservation.id)
|
|
.where(col(ReservationRelease.status) == "active")
|
|
.values(status="released")
|
|
)
|
|
if transition.rowcount != 1:
|
|
continue
|
|
|
|
values = {
|
|
"reserved_balance": col(ApiKey.reserved_balance)
|
|
- reservation.reserved_msats,
|
|
"reserved_at": case(
|
|
(
|
|
col(ApiKey.reserved_balance) - reservation.reserved_msats > 0,
|
|
col(ApiKey.reserved_at),
|
|
),
|
|
else_=None,
|
|
),
|
|
}
|
|
parent_result = await session.exec( # type: ignore[call-overload]
|
|
update(ApiKey)
|
|
.where(col(ApiKey.hashed_key) == reservation.billing_key_hash)
|
|
.where(col(ApiKey.reserved_balance) >= reservation.reserved_msats)
|
|
.values(**values)
|
|
)
|
|
if parent_result.rowcount != 1:
|
|
await session.rollback()
|
|
return 0
|
|
|
|
if reservation.billing_key_hash != reservation.key_hash:
|
|
child_result = await session.exec( # type: ignore[call-overload]
|
|
update(ApiKey)
|
|
.where(col(ApiKey.hashed_key) == reservation.key_hash)
|
|
.where(col(ApiKey.reserved_balance) >= reservation.reserved_msats)
|
|
.values(**values)
|
|
)
|
|
if child_result.rowcount != 1:
|
|
await session.rollback()
|
|
return 0
|
|
released += 1
|
|
|
|
# Rolling upgrades can leave aggregate reservations created before durable
|
|
# reservation rows existed. Release only stale aggregates that have no active
|
|
# durable owner; targeted refund cleanup also heals legacy NULL timestamps.
|
|
legacy_query = select(ApiKey).where(col(ApiKey.reserved_balance) > 0)
|
|
if key_hash is None:
|
|
legacy_query = legacy_query.where(col(ApiKey.reserved_at).is_not(None)).where(
|
|
col(ApiKey.reserved_at) < cutoff
|
|
)
|
|
else:
|
|
legacy_query = legacy_query.where(
|
|
or_(
|
|
col(ApiKey.hashed_key) == key_hash,
|
|
col(ApiKey.parent_key_hash) == key_hash,
|
|
)
|
|
).where(
|
|
or_(col(ApiKey.reserved_at).is_(None), col(ApiKey.reserved_at) < cutoff)
|
|
)
|
|
|
|
for legacy_key in (await session.exec(legacy_query)).all():
|
|
active_owner = (
|
|
await session.exec(
|
|
select(ReservationRelease.id)
|
|
.where(col(ReservationRelease.status) == "active")
|
|
.where(
|
|
or_(
|
|
col(ReservationRelease.key_hash) == legacy_key.hashed_key,
|
|
col(ReservationRelease.billing_key_hash)
|
|
== legacy_key.hashed_key,
|
|
)
|
|
)
|
|
.limit(1)
|
|
)
|
|
).first()
|
|
if active_owner is not None:
|
|
continue
|
|
legacy_key.reserved_balance = 0
|
|
legacy_key.reserved_at = None
|
|
session.add(legacy_key)
|
|
released += 1
|
|
|
|
await session.commit()
|
|
if released:
|
|
logger.warning(
|
|
"Released stale reservations",
|
|
extra={
|
|
"released_reservations": released,
|
|
"max_age_seconds": max_age_seconds,
|
|
},
|
|
)
|
|
return released
|
|
|
|
|
|
async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> int:
|
|
"""Delete dead parentless API keys; return the count removed.
|
|
|
|
Dead = 0 balance/reservation/spend/requests, older than the grace period,
|
|
no parent, no children, no pending invoice. Cashu rows are unlinked (not
|
|
deleted) first to keep the audit trail.
|
|
"""
|
|
cutoff = int(time.time()) - min_age_seconds
|
|
|
|
child = aliased(ApiKey)
|
|
has_children = (
|
|
select(child.hashed_key).where(
|
|
col(child.parent_key_hash) == col(ApiKey.hashed_key)
|
|
)
|
|
).exists()
|
|
pending_invoice = (
|
|
select(LightningInvoice.id)
|
|
.where(col(LightningInvoice.api_key_hash) == col(ApiKey.hashed_key))
|
|
.where(col(LightningInvoice.status) == "pending")
|
|
).exists()
|
|
|
|
eligible_hashes = (
|
|
select(ApiKey.hashed_key)
|
|
.where(col(ApiKey.balance) == 0)
|
|
.where(col(ApiKey.reserved_balance) == 0)
|
|
.where(col(ApiKey.total_spent) == 0)
|
|
.where(col(ApiKey.total_requests) == 0)
|
|
.where(col(ApiKey.parent_key_hash).is_(None))
|
|
.where(
|
|
(col(ApiKey.created_at).is_(None)) | (col(ApiKey.created_at) < cutoff)
|
|
)
|
|
.where(~pending_invoice)
|
|
.where(~has_children)
|
|
)
|
|
|
|
# Unlink transactions rather than cascade-deleting them, so the financial
|
|
# audit trail survives. The eligibility predicate is re-evaluated inside both
|
|
# statements so a key that gained balance mid-run is left untouched.
|
|
await session.exec( # type: ignore[call-overload]
|
|
update(CashuTransaction)
|
|
.where(col(CashuTransaction.api_key_hashed_key).in_(eligible_hashes))
|
|
.values(api_key_hashed_key=None)
|
|
)
|
|
|
|
result = await session.exec( # type: ignore[call-overload]
|
|
delete(ApiKey).where(col(ApiKey.hashed_key).in_(eligible_hashes))
|
|
)
|
|
await session.commit()
|
|
|
|
pruned = int(result.rowcount or 0)
|
|
logger.info(
|
|
"Pruned dead API keys",
|
|
extra={"pruned_keys": pruned, "min_age_seconds": min_age_seconds},
|
|
)
|
|
return pruned
|
|
|
|
|
|
class ModelRow(SQLModel, table=True): # type: ignore
|
|
__tablename__ = "models"
|
|
id: str = Field(primary_key=True)
|
|
upstream_provider_id: int = Field(
|
|
primary_key=True, foreign_key="upstream_providers.id", ondelete="CASCADE"
|
|
)
|
|
name: str = Field()
|
|
created: int = Field()
|
|
description: str = Field()
|
|
context_length: int = Field()
|
|
architecture: str = Field()
|
|
pricing: str = Field()
|
|
sats_pricing: str | None = Field(default=None)
|
|
per_request_limits: str | None = Field(default=None)
|
|
top_provider: str | None = Field(default=None)
|
|
canonical_slug: str | None = Field(default=None, description="Canonical model slug")
|
|
alias_ids: str | None = Field(
|
|
default=None, description="JSON array of model alias IDs"
|
|
)
|
|
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
|
forwarded_model_id: str | None = Field(
|
|
default=None,
|
|
description="Model ID to use when forwarding requests to upstream provider. Defaults to id if not set.",
|
|
)
|
|
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
|
|
|
|
|
class LightningInvoice(SQLModel, table=True): # type: ignore
|
|
__tablename__ = "lightning_invoices"
|
|
|
|
id: str = Field(primary_key=True, description="Unique invoice identifier")
|
|
bolt11: str = Field(description="BOLT11 invoice string", unique=True)
|
|
amount_sats: int = Field(description="Amount in satoshis")
|
|
description: str = Field(description="Invoice description")
|
|
payment_hash: str = Field(description="Payment hash for tracking", unique=True)
|
|
status: str = Field(
|
|
default="pending",
|
|
description="pending, paid, expired, cancelled, reconciliation_required",
|
|
)
|
|
api_key_hash: str | None = Field(
|
|
default=None, description="Associated API key hash for topup operations"
|
|
)
|
|
purpose: str = Field(description="create or topup")
|
|
created_at: int = Field(
|
|
default_factory=lambda: int(time.time()), description="Unix timestamp"
|
|
)
|
|
expires_at: int = Field(description="Unix timestamp when invoice expires")
|
|
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
|
balance_limit: int | None = Field(
|
|
default=None,
|
|
description="Max spendable msats for the created key",
|
|
)
|
|
balance_limit_reset: str | None = Field(
|
|
default=None,
|
|
description="Reset policy for balance limit (daily, weekly, monthly)",
|
|
)
|
|
validity_date: int | None = Field(
|
|
default=None,
|
|
description="Unix timestamp after which the created key expires",
|
|
)
|
|
|
|
|
|
class CashuTransaction(SQLModel, table=True): # type: ignore
|
|
__tablename__ = "cashu_transactions"
|
|
|
|
id: str = Field(
|
|
primary_key=True,
|
|
default_factory=lambda: uuid.uuid4().hex,
|
|
description="Unique transaction identifier",
|
|
)
|
|
token: str = Field(description="Serialized Cashu token")
|
|
amount: int = Field(description="Amount in the token's unit")
|
|
unit: str = Field(description="Token unit (sat or msat)")
|
|
mint_url: str | None = Field(default=None, description="Mint URL for the token")
|
|
type: str = Field(default="out", description="Transaction type: in or out")
|
|
request_id: str | None = Field(default=None, description="Associated request ID")
|
|
created_at: int = Field(
|
|
default_factory=lambda: int(time.time()),
|
|
description="Unix timestamp",
|
|
)
|
|
collected: bool = Field(default=False)
|
|
swept: bool = Field(default=False)
|
|
sweep_started_at: int | None = Field(
|
|
default=None,
|
|
description="Unix timestamp for a recoverable refund-sweep claim",
|
|
)
|
|
source: str = Field(
|
|
default="x-cashu",
|
|
description="Payment source: x-cashu or apikey",
|
|
)
|
|
api_key_hashed_key: str | None = Field(
|
|
default=None,
|
|
foreign_key="api_keys.hashed_key",
|
|
index=True,
|
|
description="Associated API key hash for wallet history",
|
|
)
|
|
|
|
|
|
async def store_cashu_transaction(
|
|
token: str,
|
|
amount: int,
|
|
unit: str,
|
|
mint_url: str | None = None,
|
|
typ: str = "out",
|
|
request_id: str | None = None,
|
|
collected: bool = False,
|
|
created_at: int | None = None,
|
|
source: str = "x-cashu",
|
|
api_key_hashed_key: str | None = None,
|
|
transaction_id: str | None = None,
|
|
log_failure: bool = True,
|
|
) -> bool:
|
|
try:
|
|
async with create_session() as session:
|
|
tx = CashuTransaction(
|
|
id=transaction_id or uuid.uuid4().hex,
|
|
token=token,
|
|
amount=amount,
|
|
unit=unit,
|
|
mint_url=mint_url,
|
|
type=typ,
|
|
request_id=request_id,
|
|
collected=collected,
|
|
created_at=created_at or int(time.time()),
|
|
source=source,
|
|
api_key_hashed_key=api_key_hashed_key,
|
|
)
|
|
session.add(tx)
|
|
await session.commit()
|
|
except Exception:
|
|
if log_failure:
|
|
logger.critical(
|
|
"Failed to store Cashu transaction",
|
|
extra={"type": typ, "request_id": request_id, "source": source},
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
return True
|
|
|
|
|
|
async def _cashu_transaction_exists(transaction_id: str) -> bool:
|
|
async with create_session() as session:
|
|
return await session.get(CashuTransaction, transaction_id) is not None
|
|
|
|
|
|
async def store_cashu_transaction_with_retry(
|
|
token: str,
|
|
amount: int,
|
|
unit: str,
|
|
mint_url: str | None = None,
|
|
typ: str = "out",
|
|
request_id: str | None = None,
|
|
collected: bool = False,
|
|
created_at: int | None = None,
|
|
source: str = "x-cashu",
|
|
api_key_hashed_key: str | None = None,
|
|
max_attempts: int = 3,
|
|
) -> bool:
|
|
"""Retry a critical Cashu transaction write with bounded backoff."""
|
|
transaction_id = hashlib.sha256(f"{typ}\0{token}".encode()).hexdigest()
|
|
last_error: Exception | None = None
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
return await store_cashu_transaction(
|
|
token=token,
|
|
amount=amount,
|
|
unit=unit,
|
|
mint_url=mint_url,
|
|
typ=typ,
|
|
request_id=request_id,
|
|
collected=collected,
|
|
created_at=created_at,
|
|
source=source,
|
|
api_key_hashed_key=api_key_hashed_key,
|
|
transaction_id=transaction_id,
|
|
log_failure=False,
|
|
)
|
|
except IntegrityError as error:
|
|
try:
|
|
if await _cashu_transaction_exists(transaction_id):
|
|
return True
|
|
except Exception as lookup_error:
|
|
last_error = lookup_error
|
|
else:
|
|
last_error = error
|
|
except Exception as error:
|
|
last_error = error
|
|
|
|
if last_error is not None:
|
|
if attempt == max_attempts:
|
|
break
|
|
delay = 0.25 * (2 ** (attempt - 1))
|
|
logger.warning(
|
|
"Cashu transaction storage failed; retrying",
|
|
extra={
|
|
"type": typ,
|
|
"request_id": request_id,
|
|
"attempt": attempt,
|
|
"max_attempts": max_attempts,
|
|
"retry_delay_seconds": delay,
|
|
},
|
|
)
|
|
await asyncio.sleep(delay)
|
|
|
|
logger.critical(
|
|
"Cashu transaction storage failed after bounded retries",
|
|
extra={
|
|
"type": typ,
|
|
"request_id": request_id,
|
|
"attempts": max_attempts,
|
|
"error": str(last_error),
|
|
},
|
|
)
|
|
if last_error is None:
|
|
raise RuntimeError("Cashu transaction storage failed without an exception")
|
|
raise last_error
|
|
|
|
|
|
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
|
__tablename__ = "upstream_providers"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"base_url", "api_key", name="uq_upstream_providers_base_url_api_key"
|
|
),
|
|
)
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
slug: str | None = Field(
|
|
default=None,
|
|
unique=True,
|
|
index=True,
|
|
description="Stable external slug used for updates via API key.",
|
|
)
|
|
provider_type: str = Field(
|
|
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
|
)
|
|
base_url: str = Field(description="Base URL of the upstream API")
|
|
api_key: str = Field(description="API key for the upstream provider")
|
|
api_version: str | None = Field(
|
|
default=None, description="API version for Azure OpenAI"
|
|
)
|
|
enabled: bool = Field(default=True, description="Whether this provider is enabled")
|
|
provider_fee: float = Field(
|
|
default=1.01, description="Provider fee multiplier (default 1%)"
|
|
)
|
|
provider_settings: str | None = Field(
|
|
default=None, description="JSON string for provider-specific settings"
|
|
)
|
|
models: list["ModelRow"] = Relationship(
|
|
back_populates="upstream_provider",
|
|
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
|
)
|
|
|
|
|
|
class ReservationRelease(SQLModel, table=True): # type: ignore
|
|
__tablename__ = "reservation_releases"
|
|
__table_args__ = (
|
|
Index("ix_reservation_releases_status_created_at", "status", "created_at"),
|
|
)
|
|
|
|
id: str = Field(primary_key=True)
|
|
key_hash: str = Field(index=True)
|
|
billing_key_hash: str = Field(index=True)
|
|
reserved_msats: int
|
|
status: str = Field(default="active")
|
|
created_at: int = Field(default_factory=lambda: int(time.time()))
|
|
|
|
|
|
class RoutstrFee(SQLModel, table=True): # type: ignore
|
|
__tablename__ = "routstr_fees"
|
|
id: int = Field(default=1, primary_key=True)
|
|
accumulated_msats: int = Field(default=0)
|
|
total_paid_msats: int = Field(default=0)
|
|
last_paid_at: int | None = Field(default=None)
|
|
payout_in_progress_msats: int = Field(default=0)
|
|
payout_started_at: int | None = Field(default=None)
|
|
|
|
|
|
class NsecState(str, Enum):
|
|
"""Ownership state of the node's nsec — an explicit 3-state machine.
|
|
|
|
The single ``encrypted_nsec`` column cannot distinguish "never migrated" from
|
|
"intentionally cleared" (both leave it empty), which let a cleared identity be
|
|
resurrected from a stale legacy ``NSEC``. This names the three states so the
|
|
bootstrap branches on ownership rather than inferring it:
|
|
|
|
* ``legacy`` — the vault has not taken ownership; a plaintext ``NSEC`` (env or
|
|
old settings blob) may still exist and should be migrated in once.
|
|
* ``encrypted`` — the vault owns a ciphertext; decrypt it, never re-read env.
|
|
* ``cleared`` — the vault owns it but the operator emptied it; stay empty,
|
|
never re-import from a stale legacy copy.
|
|
"""
|
|
|
|
legacy = "legacy"
|
|
encrypted = "encrypted"
|
|
cleared = "cleared"
|
|
|
|
|
|
class Secret(SQLModel, table=True): # type: ignore
|
|
"""Node-level secrets, stored encrypted/hashed at rest (singleton, id=1).
|
|
|
|
The asymmetric column names document the encoding: ``_hash`` is one-way
|
|
(scrypt, verify only) while ``encrypted_`` is reversible (Fernet). Per-provider
|
|
upstream keys live on ``upstream_providers``, not here. See ``routstr.core.vault``.
|
|
"""
|
|
|
|
__tablename__ = "secrets"
|
|
id: int = Field(default=1, primary_key=True)
|
|
admin_password_hash: str | None = Field(default=None)
|
|
encrypted_nsec: str | None = Field(default=None)
|
|
nsec_state: NsecState = Field(default=NsecState.legacy)
|
|
updated_at: int | None = Field(default=None)
|
|
|
|
|
|
class CliToken(SQLModel, table=True): # type: ignore
|
|
"""Long-lived authorization token for CLI/agent use against admin endpoints."""
|
|
|
|
__tablename__ = "cli_tokens"
|
|
id: str = Field(
|
|
primary_key=True, default_factory=lambda: uuid.uuid4().hex
|
|
)
|
|
token: str = Field(unique=True, index=True, description="Bearer token value")
|
|
name: str = Field(description="Human-readable label for this token")
|
|
created_at: int = Field(default_factory=lambda: int(time.time()))
|
|
last_used_at: int | None = Field(default=None)
|
|
expires_at: int | None = Field(
|
|
default=None, description="Optional expiry unix timestamp; null = never expires"
|
|
)
|
|
|
|
|
|
async def accumulate_routstr_fee(session: AsyncSession, amount_msats: int) -> None:
|
|
stmt = (
|
|
update(RoutstrFee)
|
|
.where(col(RoutstrFee.id) == 1)
|
|
.values(accumulated_msats=RoutstrFee.accumulated_msats + amount_msats)
|
|
)
|
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
|
if result.rowcount == 0:
|
|
session.add(RoutstrFee(id=1, accumulated_msats=amount_msats))
|
|
await session.commit()
|
|
|
|
|
|
async def get_routstr_fee(session: AsyncSession) -> RoutstrFee:
|
|
fee = await session.get(RoutstrFee, 1)
|
|
if fee is None:
|
|
fee = RoutstrFee(id=1, accumulated_msats=0, total_paid_msats=0)
|
|
session.add(fee)
|
|
await session.commit()
|
|
await session.refresh(fee)
|
|
return fee
|
|
|
|
|
|
async def get_secret(session: AsyncSession) -> Secret:
|
|
secret = await session.get(Secret, 1)
|
|
if secret is None:
|
|
secret = Secret(id=1)
|
|
session.add(secret)
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError:
|
|
# Another worker created the singleton row between our read and
|
|
# insert (multiple workers booting against one shared DB). Roll back
|
|
# and read the row they committed instead of failing startup.
|
|
await session.rollback()
|
|
secret = await session.get(Secret, 1)
|
|
if secret is None:
|
|
raise
|
|
return secret
|
|
await session.refresh(secret)
|
|
return secret
|
|
|
|
|
|
async def set_admin_password(session: AsyncSession, password: str) -> None:
|
|
"""Store the admin password as a one-way hash on the Secret singleton."""
|
|
from .vault import hash_password
|
|
|
|
secret = await get_secret(session)
|
|
secret.admin_password_hash = hash_password(password)
|
|
secret.updated_at = int(time.time())
|
|
session.add(secret)
|
|
await session.commit()
|
|
|
|
|
|
async def set_nsec(session: AsyncSession, nsec: str) -> None:
|
|
"""Store the node's nsec, Fernet-encrypted, on the Secret singleton.
|
|
|
|
An empty string clears it (the node then holds no Nostr identity and signs
|
|
no events). Either way the vault now owns the nsec, so the state moves off
|
|
``legacy``: a cleared identity (``cleared``) must not be resurrected from a
|
|
stale legacy ``NSEC`` on the next boot.
|
|
"""
|
|
from .vault import encrypt
|
|
|
|
secret = await get_secret(session)
|
|
secret.encrypted_nsec = encrypt(nsec) if nsec else None
|
|
secret.nsec_state = NsecState.encrypted if nsec else NsecState.cleared
|
|
secret.updated_at = int(time.time())
|
|
session.add(secret)
|
|
await session.commit()
|
|
|
|
|
|
async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> bool:
|
|
"""Checkpoint a fee payout before making the external payment."""
|
|
stmt = (
|
|
update(RoutstrFee)
|
|
.where(col(RoutstrFee.id) == 1)
|
|
.where(col(RoutstrFee.payout_in_progress_msats) == 0)
|
|
.where(col(RoutstrFee.accumulated_msats) >= paid_msats)
|
|
.values(
|
|
accumulated_msats=RoutstrFee.accumulated_msats - paid_msats,
|
|
payout_in_progress_msats=paid_msats,
|
|
payout_started_at=int(time.time()),
|
|
)
|
|
)
|
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
|
await session.commit()
|
|
return result.rowcount == 1
|
|
|
|
|
|
async def complete_routstr_fee_payout(
|
|
session: AsyncSession, paid_msats: int
|
|
) -> bool:
|
|
"""Mark a checkpointed payout complete after the external payment succeeds."""
|
|
stmt = (
|
|
update(RoutstrFee)
|
|
.where(col(RoutstrFee.id) == 1)
|
|
.where(col(RoutstrFee.payout_in_progress_msats) == paid_msats)
|
|
.values(
|
|
payout_in_progress_msats=0,
|
|
payout_started_at=None,
|
|
total_paid_msats=RoutstrFee.total_paid_msats + paid_msats,
|
|
last_paid_at=int(time.time()),
|
|
)
|
|
)
|
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
|
await session.commit()
|
|
return result.rowcount == 1
|
|
|
|
|
|
async def total_user_liability(db_session: AsyncSession) -> int:
|
|
"""Return all outstanding API-key balances in millisatoshis."""
|
|
result = await db_session.exec(select(func.sum(ApiKey.balance)))
|
|
return int(result.one() or 0)
|
|
|
|
|
|
async def balance_for_mint_and_unit(
|
|
db_session: AsyncSession, mint_url: str, unit: str
|
|
) -> int:
|
|
"""Return the user liability for one mint and unit in millisatoshis."""
|
|
result = await db_session.exec(
|
|
select(func.sum(ApiKey.balance)).where(
|
|
col(ApiKey.refund_mint_url) == mint_url,
|
|
col(ApiKey.refund_currency) == unit,
|
|
)
|
|
)
|
|
return int(result.one() or 0)
|
|
|
|
|
|
async def balances_by_mint_and_unit(
|
|
db_session: AsyncSession, mint_urls: list[str], units: list[str]
|
|
) -> dict[tuple[str, str], int]:
|
|
"""Return requested user liabilities grouped by mint and unit."""
|
|
if not mint_urls or not units:
|
|
return {}
|
|
query = (
|
|
select(
|
|
col(ApiKey.refund_mint_url),
|
|
col(ApiKey.refund_currency),
|
|
func.sum(ApiKey.balance),
|
|
)
|
|
.where(
|
|
col(ApiKey.refund_mint_url).in_(mint_urls),
|
|
col(ApiKey.refund_currency).in_(units),
|
|
)
|
|
.group_by(col(ApiKey.refund_mint_url), col(ApiKey.refund_currency))
|
|
)
|
|
result = await db_session.exec(query)
|
|
return {
|
|
(mint_url, unit): int(balance or 0)
|
|
for mint_url, unit, balance in result.all()
|
|
if mint_url is not None and unit is not None
|
|
}
|
|
|
|
|
|
async def init_db() -> None:
|
|
"""Initializes the database and creates tables if they don't exist."""
|
|
async with engine.begin() as conn:
|
|
if DATABASE_URL.startswith("sqlite"):
|
|
await conn.exec_driver_sql("PRAGMA journal_mode=WAL")
|
|
await conn.run_sync(SQLModel.metadata.create_all)
|
|
|
|
|
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
yield session
|
|
|
|
|
|
@asynccontextmanager
|
|
async def create_session() -> AsyncGenerator[AsyncSession, None]:
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
yield session
|
|
|
|
|
|
def fix_cashu_migrations() -> None:
|
|
"""
|
|
Fixes Cashu wallet migrations that are not idempotent.
|
|
This specifically addresses the 'duplicate column name: public_keys' error
|
|
in the keysets table of Cashu's internal SQLite databases.
|
|
"""
|
|
project_root = pathlib.Path(__file__).resolve().parents[2]
|
|
wallet_dir = project_root / ".wallet"
|
|
|
|
if not wallet_dir.exists() or not wallet_dir.is_dir():
|
|
return
|
|
|
|
logger.info("Checking Cashu wallet databases for migration idempotency")
|
|
|
|
for db_file in wallet_dir.glob("*.sqlite3"):
|
|
try:
|
|
conn = sqlite3.connect(db_file)
|
|
cursor = conn.cursor()
|
|
|
|
# Check if keysets table exists
|
|
cursor.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'"
|
|
)
|
|
if not cursor.fetchone():
|
|
conn.close()
|
|
continue
|
|
|
|
# Check if public_keys column exists
|
|
cursor.execute("PRAGMA table_info(keysets)")
|
|
columns = [info[1] for info in cursor.fetchall()]
|
|
|
|
if "public_keys" not in columns:
|
|
logger.info(f"Adding missing public_keys column to {db_file.name}")
|
|
cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT")
|
|
conn.commit()
|
|
|
|
conn.close()
|
|
except Exception as e:
|
|
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
|
|
|
|
|
|
def _clear_alembic_version() -> None:
|
|
"""Clear the alembic_version table so stamp/upgrade can proceed."""
|
|
sync_url = DATABASE_URL.replace("+aiosqlite", "")
|
|
from sqlalchemy import create_engine, text
|
|
|
|
eng = create_engine(sync_url)
|
|
with eng.begin() as conn:
|
|
conn.execute(text("DELETE FROM alembic_version"))
|
|
eng.dispose()
|
|
|
|
|
|
def run_migrations() -> None:
|
|
"""Run Alembic migrations programmatically."""
|
|
try:
|
|
# Run Cashu migration fix first
|
|
fix_cashu_migrations()
|
|
|
|
# Get the path to the alembic.ini file
|
|
project_root = pathlib.Path(__file__).resolve().parents[2]
|
|
alembic_ini_path = project_root / "alembic.ini"
|
|
|
|
if not alembic_ini_path.exists():
|
|
raise FileNotFoundError(
|
|
f"Alembic configuration file not found at {alembic_ini_path}"
|
|
)
|
|
|
|
# Create Alembic config object
|
|
alembic_cfg = Config(str(alembic_ini_path))
|
|
|
|
# Set the database URL in the config
|
|
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
|
|
|
try:
|
|
command.upgrade(alembic_cfg, "head")
|
|
except CommandError as e:
|
|
if "Can't locate revision" in str(e):
|
|
logger.warning(
|
|
"Database stamped with unknown revision (likely from another branch). "
|
|
"Re-stamping to current head.",
|
|
extra={"error": str(e)},
|
|
)
|
|
_clear_alembic_version()
|
|
command.stamp(alembic_cfg, "head")
|
|
else:
|
|
raise
|
|
except OperationalError as e:
|
|
if "duplicate column name" in str(e).lower():
|
|
logger.warning(
|
|
"Migration hit a column that already exists (likely added via "
|
|
"create_all on another branch). Stamping to current head.",
|
|
extra={"error": str(e)},
|
|
)
|
|
_clear_alembic_version()
|
|
command.stamp(alembic_cfg, "head")
|
|
else:
|
|
raise
|
|
|
|
logger.info("Database migrations completed successfully")
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Database migration failed",
|
|
extra={"error": str(e), "error_type": type(e).__name__},
|
|
)
|
|
raise
|