Remove child key feature and balance limits completely

Removes the child-key (shared parent balance) feature and the
balance-limit machinery from the entire stack:

Backend:
- Drop POST /v1/balance/child-key and /v1/balance/child-key/reset
- Remove parent-key billing indirection (get_billing_key); every key
  is charged directly
- Remove balance_limit/balance_limit_reset enforcement, reset
  policies, and the periodic limit-reset background task
- Remove child-key guards on refund/history endpoints
- Remove child_key_cost setting and /v1/info child_key_cost_msats
- Remove balance_limit fields from LightningInvoice model and
  invoice-creation API
- Admin balances API returns plain sums (no parent/child split)

Migration (e5a6b7c8d9f0):
- Nulls parent_key_hash on children (they become standalone keys;
  parents keep 100% of their balance, so no funds are lost)
- Drops parent_key_hash + balance_limit columns from api_keys and
  balance_limit columns from lightning_invoices
- Verified: upgrade, downgrade, and fund preservation round-trip

UI:
- Remove child-key creator, child key panels, balance-limit inputs
- Key options now offer validity date only
- Temporary balances table renders all keys uniformly

Tests: child-key suites removed; remaining suites converted to
single-key semantics (441 integration + 1313 unit tests pass).
Docs updated accordingly.
This commit is contained in:
thefux
2026-09-03 11:33:39 +00:00
parent c3093d2bcc
commit 47807ae92e
35 changed files with 189 additions and 2924 deletions
-17
View File
@@ -307,23 +307,6 @@ ANALYTICS_KEY = os.getenv("ROUTSTR_ANALYTICS_KEY")
api_key = PROD_KEY if is_production() else DEV_KEY
```
### Delegated Authentication
Create sub-keys with limited permissions:
```bash
POST /v1/wallet/create/subkey
Authorization: Bearer sk-parent-key
Content-Type: application/json
{
"name": "Limited Subkey",
"balance_limit": 1000,
"allowed_models": ["gpt-3.5-turbo"],
"expires_in_hours": 24
}
```
## Rate Limiting
Rate limits are applied per API key:
+2 -52
View File
@@ -418,7 +418,7 @@ POST /v1/wallet/create
### Get Key Information
Get current balance, consumption data, and child keys for an API key.
Get current balance and consumption data for an API key.
```http
GET /v1/balance/info
@@ -432,23 +432,9 @@ Authorization: Bearer sk-...
"api_key": "sk-abc...",
"balance": 8500000,
"reserved": 0,
"is_child": false,
"parent_key": null,
"total_requests": 42,
"total_spent": 1500000,
"balance_limit": null,
"balance_limit_reset": null,
"validity_date": null,
"child_keys": [
{
"api_key": "sk-child1...",
"total_requests": 10,
"total_spent": 500000,
"balance_limit": 1000000,
"balance_limit_reset": "daily",
"validity_date": 1738000000
}
]
"validity_date": null
}
```
@@ -528,42 +514,6 @@ Authorization: Bearer sk-...
}
```
### Create Child Key
Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
```http
POST /v1/balance/child-key
Authorization: Bearer sk-...
```
**Request Body:**
```json
{
"count": 1
}
```
**Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `count` | integer | Yes | - | Number of child keys to create (1-50) |
**Response:**
```json
{
"api_keys": ["sk-abc...", "sk-def..."],
"count": 2,
"cost_msats": 2000,
"cost_sats": 2,
"parent_balance": 98000,
"parent_balance_sats": 98
}
```
## Provider Discovery
## Admin Settings
-45
View File
@@ -1,45 +0,0 @@
import json
import sys
import httpx
def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
headers = {"Authorization": f"Bearer {api_key}"}
print(f"Requesting {count} child keys from {base_url}...")
child_keys = []
for i in range(count):
try:
response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
if response.status_code == 200:
data = response.json()
child_keys.append(data["api_key"])
print(
f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
)
else:
print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
except Exception as e:
print(f" [{i + 1}] Error: {str(e)}")
return child_keys
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python create_child_keys.py <api_key_or_cashu_token> [base_url]")
sys.exit(1)
auth_key = sys.argv[1]
base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
keys = create_child_keys(base_url, auth_key)
if keys:
print("\nSuccessfully created child keys:")
print(json.dumps(keys, indent=2))
else:
print("\nNo child keys were created.")
@@ -0,0 +1,76 @@
"""Remove child keys and balance limits.
Removes the child-key feature (parent_key_hash) and the balance-limit
machinery (balance_limit, balance_limit_reset, balance_limit_reset_date)
from api_keys, plus the balance_limit/balance_limit_reset pass-through on
lightning_invoices.
Data preservation: before dropping the columns, every child key is
converted into a standalone key by clearing parent_key_hash. Child keys
never hold their own balance (they always spent from their parent), so no
funds are lost: the parent keeps its full balance, and the former child
rows are preserved with their total_spent/total_requests history intact.
"""
import sqlalchemy as sa
from alembic import op
revision = "e5a6b7c8d9f0"
down_revision = "b4f7a1c9d2e3"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Convert child keys into standalone keys before dropping the link.
# Their balance is always 0 (they spent from the parent), so this
# cannot strand any funds.
op.execute("UPDATE api_keys SET parent_key_hash = NULL")
with op.batch_alter_table("api_keys") as batch_op:
batch_op.drop_index("ix_api_keys_parent_key_hash")
batch_op.drop_column("parent_key_hash")
batch_op.drop_column("balance_limit")
batch_op.drop_column("balance_limit_reset")
batch_op.drop_column("balance_limit_reset_date")
with op.batch_alter_table("lightning_invoices") as batch_op:
batch_op.drop_column("balance_limit")
batch_op.drop_column("balance_limit_reset")
def downgrade() -> None:
with op.batch_alter_table("lightning_invoices") as batch_op:
batch_op.add_column(sa.Column("balance_limit", sa.Integer(), nullable=True))
batch_op.add_column(
sa.Column("balance_limit_reset", sa.String(), nullable=True)
)
with op.batch_alter_table("api_keys") as batch_op:
batch_op.add_column(
sa.Column("balance_limit_reset_date", sa.Integer(), nullable=True)
)
batch_op.add_column(
sa.Column(
"balance_limit_reset",
sa.String(),
nullable=True,
)
)
batch_op.add_column(sa.Column("balance_limit", sa.Integer(), nullable=True))
batch_op.add_column(
sa.Column(
"parent_key_hash",
sa.String(),
nullable=True,
)
)
batch_op.create_foreign_key(
"fk_api_keys_parent_key_hash",
"api_keys",
["parent_key_hash"],
["hashed_key"],
)
batch_op.create_index(
"ix_api_keys_parent_key_hash", ["parent_key_hash"], unique=False
)
+9 -288
View File
@@ -1,13 +1,11 @@
import asyncio
import hashlib
import math
import random
import time
import uuid
from contextlib import suppress
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from fastapi import HTTPException
@@ -99,48 +97,6 @@ def _clear_current_reservation(snapshot: ReservationSnapshot) -> None:
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
"""Checks if a key's balance limit should be reset based on its policy."""
if key.balance_limit is not None and key.balance_limit_reset:
now = int(time.time())
reset_date = key.balance_limit_reset_date or 0
should_reset = False
if key.balance_limit_reset == "daily":
if (
datetime.fromtimestamp(now).date()
> datetime.fromtimestamp(reset_date).date()
):
should_reset = True
elif key.balance_limit_reset == "weekly":
if (
datetime.fromtimestamp(now).isocalendar()[:2]
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
):
should_reset = True
elif key.balance_limit_reset == "monthly":
dt_now = datetime.fromtimestamp(now)
dt_reset = datetime.fromtimestamp(reset_date)
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
should_reset = True
if should_reset:
logger.info(
"Resetting balance limit for key",
extra={
"key_hash": key.hashed_key[:8] + "...",
"policy": key.balance_limit_reset,
"old_spent": key.total_spent,
},
)
key.total_spent = 0
key.balance_limit_reset_date = now
session.add(key)
await session.flush()
return True
return False
def redemption_error_to_http_exception(error: Exception) -> HTTPException:
"""Map a Cashu token redemption failure to a sanitized client-facing error.
@@ -315,42 +271,19 @@ async def _validate_bearer_key_locked(
},
)
# Check and reset limit if needed
await check_and_reset_limit(existing_key, session)
# Early check: Billing balance check (Parent balance)
billing_key = await get_billing_key(existing_key, session)
if min_cost > 0 and billing_key.total_balance < min_cost:
# Early check: Billing balance check
if min_cost > 0 and existing_key.total_balance < min_cost:
logger.warning(
"Insufficient billing balance during validation",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"balance": billing_key.total_balance,
"balance": existing_key.total_balance,
"required": min_cost,
},
)
raise HTTPException(
status_code=402,
detail=_model_balance_error(min_cost, billing_key.total_balance),
)
# Early check: Spending limit check (Child key limit)
if (
min_cost > 0
and existing_key.balance_limit is not None
and existing_key.total_spent + existing_key.reserved_balance + min_cost
> existing_key.balance_limit
):
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Balance limit exceeded: {existing_key.balance_limit} mSats limit. {existing_key.total_spent} already spent ({existing_key.reserved_balance} reserved), {min_cost} minimum required for this model.",
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
detail=_model_balance_error(min_cost, existing_key.total_balance),
)
return existing_key
@@ -619,27 +552,6 @@ async def _validate_bearer_key_locked(
)
async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
"""Returns the key that should be charged for the request."""
if key.parent_key_hash:
parent = await session.get(ApiKey, key.parent_key_hash)
if parent:
# We want to keep the total_requests and total_spent on the child key
# but use the balance and reserved_balance of the parent.
# However, pay_for_request updates reserved_balance and total_requests.
# To stay simple, we charge the parent's balance and update parent's total_requests.
return parent
else:
logger.error(
"Parent key not found for child key",
extra={
"child_key_hash": key.hashed_key[:8] + "...",
"parent_key_hash": key.parent_key_hash[:8] + "...",
},
)
return key
async def pay_for_request(
key: ApiKey, cost_per_request: int, session: AsyncSession
) -> int:
@@ -647,7 +559,7 @@ async def pay_for_request(
# Ensure cost_per_request is at least the minimum allowed request cost
cost_per_request = max(cost_per_request, settings.min_request_msat)
billing_key = await get_billing_key(key, session)
billing_key = key
logger.info(
"Processing payment for request",
@@ -706,35 +618,6 @@ async def pay_for_request(
},
)
# Check balance limit for child keys (or any key with a limit)
if key.balance_limit is not None:
await check_and_reset_limit(key, session)
if (
key.total_spent + key.reserved_balance + cost_per_request
> key.balance_limit
):
logger.warning(
"Balance limit exceeded",
extra={
"key_hash": key.hashed_key[:8] + "...",
"total_spent": key.total_spent,
"reserved": key.reserved_balance,
"balance_limit": key.balance_limit,
"required": cost_per_request,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
)
logger.debug(
"Charging base cost for request",
extra={
@@ -798,52 +681,6 @@ async def pay_for_request(
},
)
# Also increment total_requests and reserved_balance on the child key if it's different.
# The balance_limit guard is enforced atomically here — the Python pre-check above
# is a fast-path rejection only and provides no concurrency guarantee.
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(
(col(ApiKey.balance_limit).is_(None))
| (
col(ApiKey.total_spent)
+ col(ApiKey.reserved_balance)
+ cost_per_request
<= col(ApiKey.balance_limit)
)
)
.values(
total_requests=col(ApiKey.total_requests) + 1,
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
reserved_at=reserved_at_now,
)
)
child_result = await session.exec(child_stmt) # type: ignore[call-overload]
if child_result.rowcount == 0:
# Build the error before rollback expires ORM attributes.
limit_message = (
f"Balance limit exceeded: {key.balance_limit} mSats limit. "
f"{key.total_spent} already spent ({key.reserved_balance} reserved), "
f"{cost_per_request} required for this request."
)
# The parent reservation update already ran in this transaction.
# Roll it back before failover code attempts to restore the previous
# reservation; otherwise that later commit can persist both updates.
await session.rollback()
raise HTTPException(
status_code=402,
detail={
"error": {
"message": limit_message,
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
)
session.add(
ReservationRelease(
id=reservation.release_id,
@@ -895,8 +732,6 @@ async def pay_for_request(
try:
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
except Exception:
# The reservation transaction is already committed and durable. Logging
# refresh failures must not make the caller treat it as unreserved.
@@ -968,9 +803,6 @@ async def _validate_reservation_snapshot(
persisted_key = await session.get(ApiKey, snapshot.key_hash)
if persisted_key is None:
raise RuntimeError("Billing reservation key no longer exists")
expected_billing_hash = persisted_key.parent_key_hash or persisted_key.hashed_key
if snapshot.billing_key_hash != expected_billing_hash:
raise RuntimeError("Billing reservation does not belong to this billing key")
record = await session.get(ReservationRelease, snapshot.release_id)
if (
@@ -1184,22 +1016,6 @@ async def _transition_reservation_to_released(
snapshot, session, decrement_requests=decrement_requests
)
if snapshot.billing_key_hash != snapshot.key_hash:
child_release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == snapshot.key_hash)
.where(col(ApiKey.reserved_balance) >= snapshot.reserved_msats)
.values(**values)
)
child_result = await session.exec( # type: ignore[call-overload]
child_release_stmt
)
if child_result.rowcount != 1:
await session.rollback()
return await _repair_corrupt_reservation(
snapshot, session, decrement_requests=decrement_requests
)
await session.commit()
await _stop_reservation_heartbeat(snapshot.release_id)
_clear_current_reservation(snapshot)
@@ -1251,15 +1067,14 @@ async def _charge_reservation_rows(
session: AsyncSession,
*,
billing_key_hash: str,
key_hash: str,
reserved_msats: int,
charge_msats: int,
extra_billing_guards: tuple = (),
) -> bool:
"""Release the reserved amount and record the charge on the billing row
(and the child row when different) inside the caller's transaction.
"""Release the reserved amount and record the charge on the key
inside the caller's transaction.
Guarded subtraction replaces defensive clamping: every row must still hold
Guarded subtraction replaces defensive clamping: the row must still hold
the full reserved amount, otherwise the whole transaction rolls back and
nothing is charged. A violated invariant must never silently erase the
aggregate reservations of sibling requests. Returns False after rollback.
@@ -1288,26 +1103,6 @@ async def _charge_reservation_rows(
await session.rollback()
return False
if key_hash != billing_key_hash:
child_result = await session.exec( # type: ignore[call-overload]
update(ApiKey)
.where(col(ApiKey.hashed_key) == key_hash)
.where(col(ApiKey.reserved_balance) >= reserved_msats)
.values(
reserved_balance=col(ApiKey.reserved_balance) - reserved_msats,
reserved_at=case(
(
col(ApiKey.reserved_balance) - reserved_msats > 0,
col(ApiKey.reserved_at),
),
else_=None,
),
total_spent=col(ApiKey.total_spent) + charge_msats,
)
)
if child_result.rowcount != 1:
await session.rollback()
return False
return True
@@ -1332,7 +1127,7 @@ async def adjust_payment_for_tokens(
The response's usage object is normalized with the default union parser in
``calculate_cost``.
"""
billing_key = await get_billing_key(key, session)
billing_key = key
reservation = reservation_snapshot or await get_reservation_snapshot(key, session)
await _validate_reservation_snapshot(
key, reservation, session, require_active=False
@@ -1421,7 +1216,6 @@ async def adjust_payment_for_tokens(
charged = await _charge_reservation_rows(
session,
billing_key_hash=billing_key.hashed_key,
key_hash=key.hashed_key,
reserved_msats=deducted_max_cost,
charge_msats=cost.total_msats,
)
@@ -1444,8 +1238,6 @@ async def adjust_payment_for_tokens(
else:
cost.charged_msats = cost.total_msats
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Max cost payment finalized",
extra={
@@ -1512,7 +1304,6 @@ async def adjust_payment_for_tokens(
if not await _charge_reservation_rows(
session,
billing_key_hash=billing_key.hashed_key,
key_hash=key.hashed_key,
reserved_msats=deducted_max_cost,
charge_msats=total_cost_msats,
):
@@ -1534,8 +1325,6 @@ async def adjust_payment_for_tokens(
await _stop_reservation_heartbeat(reservation.release_id)
cost.charged_msats = total_cost_msats
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
await _accumulate_fee(total_cost_msats)
payments_logger.info(
"FINALIZE",
@@ -1600,7 +1389,6 @@ async def adjust_payment_for_tokens(
if await _charge_reservation_rows(
session,
billing_key_hash=billing_key.hashed_key,
key_hash=key.hashed_key,
reserved_msats=deducted_max_cost,
charge_msats=actual_charge_msats,
extra_billing_guards=(
@@ -1620,8 +1408,6 @@ async def adjust_payment_for_tokens(
await _stop_reservation_heartbeat(reservation.release_id)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
cost.charged_msats = actual_charge_msats
if actual_charge_msats < total_cost_msats:
logger.warning(
@@ -1680,7 +1466,6 @@ async def adjust_payment_for_tokens(
charged = await _charge_reservation_rows(
session,
billing_key_hash=billing_key.hashed_key,
key_hash=key.hashed_key,
reserved_msats=deducted_max_cost,
charge_msats=total_cost_msats,
)
@@ -1705,8 +1490,6 @@ async def adjust_payment_for_tokens(
cost.total_msats = total_cost_msats
cost.charged_msats = total_cost_msats
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Refund processed successfully",
@@ -1767,68 +1550,6 @@ async def adjust_payment_for_tokens(
raise AssertionError("Unreachable: unhandled calculate_cost result")
async def periodic_key_reset() -> None:
"""Background task to reset key limits based on their policy."""
from .core.db import create_session
while True:
try:
interval = 3600 # Run every hour
jitter = 300
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
try:
async with create_session() as session:
# Find all keys that have a reset policy
stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore
keys = (await session.exec(stmt)).all()
now = int(time.time())
updated_count = 0
for key in keys:
reset_date = key.balance_limit_reset_date or 0
should_reset = False
if key.balance_limit_reset == "daily":
if (
datetime.fromtimestamp(now).date()
> datetime.fromtimestamp(reset_date).date()
):
should_reset = True
elif key.balance_limit_reset == "weekly":
if (
datetime.fromtimestamp(now).isocalendar()[:2]
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
):
should_reset = True
elif key.balance_limit_reset == "monthly":
dt_now = datetime.fromtimestamp(now)
dt_reset = datetime.fromtimestamp(reset_date)
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
should_reset = True
if should_reset:
key.total_spent = 0
key.balance_limit_reset_date = now
session.add(key)
updated_count += 1
if updated_count > 0:
await session.commit()
logger.info(
"Periodic key reset complete",
extra={"keys_reset": updated_count},
)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in periodic_key_reset: {e}")
async def periodic_dead_key_prune() -> None:
"""Periodically prune dead API keys. Interval <= 0 disables it.
+4 -183
View File
@@ -1,6 +1,5 @@
import asyncio
import hashlib
import time
from time import monotonic
from typing import Annotated, NoReturn
@@ -10,7 +9,6 @@ from pydantic import BaseModel
from sqlmodel import col, select, update
from .auth import (
get_billing_key,
redemption_error_to_http_exception,
validate_bearer_key,
)
@@ -58,39 +56,15 @@ async def get_key_from_header(
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
billing_key = await get_billing_key(key, session)
info = {
"api_key": "sk-" + key.hashed_key,
"balance": billing_key.total_balance,
"reserved": billing_key.reserved_balance,
"is_child": key.parent_key_hash is not None,
"balance": key.total_balance,
"reserved": key.reserved_balance,
"total_requests": key.total_requests,
"total_spent": key.total_spent,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
if key.parent_key_hash:
info["parent_key_preview"] = key.parent_key_hash[:8] + "..."
else:
# Fetch child keys if this is a parent key
statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
results = await session.exec(statement)
child_keys = results.all()
if child_keys:
info["child_keys"] = [
{
"api_key": "sk-" + ck.hashed_key,
"total_requests": ck.total_requests,
"total_spent": ck.total_spent,
"balance_limit": ck.balance_limit,
"balance_limit_reset": ck.balance_limit_reset,
"validity_date": ck.validity_date,
}
for ck in child_keys
]
return info
@@ -117,26 +91,18 @@ async def account_info(
class BalanceCreateRequest(BaseModel):
initial_balance_token: str
balance_limit: int | None = None
balance_limit_reset: str | None = None
validity_date: int | None = None
async def _create_balance(
initial_balance_token: str,
balance_limit: int | None,
balance_limit_reset: str | None,
validity_date: int | None,
session: AsyncSession,
) -> dict:
key = await validate_bearer_key(initial_balance_token, session)
if balance_limit is not None or balance_limit_reset or validity_date:
key.balance_limit = balance_limit
key.balance_limit_reset = balance_limit_reset
if validity_date is not None:
key.validity_date = validity_date
if balance_limit_reset:
key.balance_limit_reset_date = int(time.time())
session.add(key)
await session.commit()
await session.refresh(key)
@@ -154,8 +120,6 @@ async def create_balance_from_body(
) -> dict:
return await _create_balance(
payload.initial_balance_token,
payload.balance_limit,
payload.balance_limit_reset,
payload.validity_date,
session,
)
@@ -164,15 +128,11 @@ async def create_balance_from_body(
@router.get("/create")
async def create_balance(
initial_balance_token: str,
balance_limit: int | None = None,
balance_limit_reset: str | None = None,
validity_date: int | None = None,
session: AsyncSession = Depends(get_session),
) -> dict:
return await _create_balance(
initial_balance_token,
balance_limit,
balance_limit_reset,
validity_date,
session,
)
@@ -208,7 +168,7 @@ async def topup_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
billing_key = await get_billing_key(key, session)
billing_key = key
if topup_request is not None:
cashu_token = topup_request.cashu_token
@@ -458,12 +418,6 @@ async def refund_wallet_endpoint(
if persisted := await _get_persisted_api_key_refund(key, session):
return persisted
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot refund child key. Please refund the parent key instead.",
)
if key.reserved_balance > 0:
# Release only durable reservations old enough to be stale. A newer
# request on the same aggregate balance must remain reserved.
@@ -650,12 +604,6 @@ async def wallet_history(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, list[dict[str, str | int | bool | None]]]:
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot view child key history. Please use the parent key instead.",
)
result = await session.exec(
select(CashuTransaction)
.where(CashuTransaction.api_key_hashed_key == key.hashed_key)
@@ -692,133 +640,6 @@ async def donate(token: str, ref: str | None = None) -> str:
except Exception:
return "Invalid token."
class ChildKeyRequest(BaseModel):
count: int
balance_limit: int | None = None
balance_limit_reset: str | None = None
validity_date: int | None = None
@router.post("/child-key")
async def create_child_key(
payload: ChildKeyRequest,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Creates one or more child API keys that use the parent's balance."""
# Log incoming request for debugging
logger.debug(f"Child key creation request: count={payload.count}")
count = payload.count
if count < 1 or count > 50:
raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
# Check if this is already a child key
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot create a child key for another child key.",
)
cost_per_key = settings.child_key_cost
total_cost = cost_per_key * count
if key.total_balance < total_cost:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Deduct cost from parent atomically — guards against concurrent requests
# that both pass the balance check above on stale in-memory state.
deduct_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= total_cost)
.values(
balance=col(ApiKey.balance) - total_cost,
total_spent=col(ApiKey.total_spent) + total_cost,
)
)
result = await session.exec(deduct_stmt) # type: ignore[call-overload]
if result.rowcount == 0:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Generate new keys
import secrets
new_keys = []
for _ in range(count):
new_key_raw = secrets.token_hex(32)
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
child_key = ApiKey(
hashed_key=new_key_hash,
balance=0,
parent_key_hash=key.hashed_key,
balance_limit=payload.balance_limit,
balance_limit_reset=payload.balance_limit_reset,
balance_limit_reset_date=int(time.time())
if payload.balance_limit_reset
else None,
validity_date=payload.validity_date,
)
session.add(child_key)
new_keys.append("sk-" + new_key_hash)
await session.commit()
await session.refresh(key)
response_data = {
"api_keys": new_keys,
"count": count,
"cost_msats": total_cost,
"cost_sats": total_cost // 1000,
"parent_balance": key.balance,
"parent_balance_sats": key.balance // 1000,
}
logger.debug(f"Child key creation response: {response_data}")
return response_data
class ChildKeyResetRequest(BaseModel):
child_key: str
@router.post("/child-key/reset")
async def reset_child_key_spent(
payload: ChildKeyResetRequest,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Resets the total_spent of a child key. Must be called by the parent."""
child_key_raw = payload.child_key
if child_key_raw.startswith("sk-"):
child_key_raw = child_key_raw[3:]
child_key = await session.get(ApiKey, child_key_raw)
if not child_key:
raise HTTPException(status_code=404, detail="Child key not found.")
if child_key.parent_key_hash != key.hashed_key:
raise HTTPException(
status_code=403, detail="Unauthorized. You are not the parent of this key."
)
child_key.total_spent = 0
if child_key.balance_limit_reset:
child_key.balance_limit_reset_date = int(time.time())
session.add(child_key)
await session.commit()
return {"success": True, "message": "Child key balance reset successfully."}
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],
+4 -47
View File
@@ -112,42 +112,12 @@ async def get_temporary_balances_api(
total = count_result.one()
# Aggregate totals across the whole (search-filtered) set, not just the
# current page. Balance counts only parent (non-child) keys to avoid
# double-counting, since child keys draw from their parent's balance.
balance_totals_result = await session.exec(
select(
func.coalesce(func.sum(ApiKey.balance), 0),
func.coalesce(func.sum(ApiKey.reserved_balance), 0),
func.coalesce(
func.sum(
case(
(col(ApiKey.parent_key_hash).is_(None), ApiKey.balance),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case(
(
col(ApiKey.parent_key_hash).is_(None),
ApiKey.reserved_balance,
),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case(
(
col(ApiKey.parent_key_hash).is_(None),
col(ApiKey.balance) - col(ApiKey.reserved_balance),
),
else_=0,
)
),
0,
func.sum(col(ApiKey.balance) - col(ApiKey.reserved_balance)), 0
),
).where(*filters)
)
@@ -184,16 +154,11 @@ async def get_temporary_balances_api(
"hashed_key": key.hashed_key,
"balance": key.balance,
"reserved_balance": key.reserved_balance,
"available_balance": (
key.total_balance if key.parent_key_hash is None else None
),
"available_balance": key.total_balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
"parent_key_hash": key.parent_key_hash,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
"created_at": key.created_at,
}
@@ -211,8 +176,6 @@ async def get_temporary_balances_api(
class ApiKeyUpdate(BaseModel):
balance_limit: int | None = None
balance_limit_reset: str | None = None
validity_date: int | None = None
@@ -227,10 +190,6 @@ async def update_apikey(
if not key:
raise HTTPException(status_code=404, detail="API key not found")
if update.balance_limit is not None:
key.balance_limit = update.balance_limit
if update.balance_limit_reset is not None:
key.balance_limit_reset = update.balance_limit_reset
if update.validity_date is not None:
key.validity_date = update.validity_date
@@ -240,8 +199,6 @@ async def update_apikey(
return {
"hashed_key": key.hashed_key,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
+4 -39
View File
@@ -17,7 +17,6 @@ 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
@@ -138,21 +137,6 @@ class ApiKey(SQLModel, table=True): # type: ignore
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",
@@ -317,10 +301,7 @@ async def release_stale_reservations(
)
else:
legacy_query = legacy_query.where(
or_(
col(ApiKey.hashed_key) == key_hash,
col(ApiKey.parent_key_hash) == key_hash,
)
col(ApiKey.hashed_key) == key_hash
).where(
or_(col(ApiKey.reserved_at).is_(None), col(ApiKey.reserved_at) < cutoff)
)
@@ -362,21 +343,15 @@ async def release_stale_reservations(
async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> int:
"""Delete dead parentless API keys; return the count removed.
"""Delete dead API keys; return the count removed.
Dead = 0 balance/reservation/spend/requests, older than the grace period,
no parent, no children, no invoice that could still settle. Cashu rows are
Dead = 0 balance/reservation/spend/requests, older than the grace
period, no invoice that could still settle. Cashu rows are
unlinked (not deleted) first to keep the audit trail.
"""
now = int(time.time())
cutoff = now - min_age_seconds
child = aliased(ApiKey)
has_children = (
select(child.hashed_key).where(
col(child.parent_key_hash) == col(ApiKey.hashed_key)
)
).exists()
# An expired invoice stays creditable for the grace window, and crediting it
# after its target key is gone strands the payment at the mint.
settleable_invoice = (
@@ -400,10 +375,8 @@ async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> in
.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(~settleable_invoice)
.where(~has_children)
)
# Unlink transactions rather than cascade-deleting them, so the financial
@@ -542,14 +515,6 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
)
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",
-3
View File
@@ -14,7 +14,6 @@ from starlette.types import Scope
from ..auth import (
periodic_dead_key_prune,
periodic_key_reset,
periodic_stale_reservation_sweep,
)
from ..balance import balance_router, deprecated_wallet_router
@@ -149,7 +148,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
analytics_task = asyncio.create_task(publish_usage_analytics())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
key_reset_task = asyncio.create_task(periodic_key_reset())
stale_reservation_task = asyncio.create_task(periodic_stale_reservation_sweep())
dead_key_prune_task = asyncio.create_task(periodic_dead_key_prune())
auto_topup_task = asyncio.create_task(periodic_auto_topup())
@@ -308,7 +306,6 @@ async def info() -> dict:
"mints": global_settings.cashu_mints,
"http_url": global_settings.http_url,
"onion_url": global_settings.onion_url,
"child_key_cost_msats": global_settings.child_key_cost,
}
-1
View File
@@ -77,7 +77,6 @@ class Settings(BaseSettings):
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
child_key_cost: int = Field(default=0, env="CHILD_KEY_COST")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
reset_reserved_balance_on_startup: bool = Field(
-10
View File
@@ -76,8 +76,6 @@ class _InvoiceSettlement:
purpose: str
api_key_hash: str | None
mint_url: str | None
balance_limit: int | None
balance_limit_reset: str | None
validity_date: int | None
@classmethod
@@ -89,8 +87,6 @@ class _InvoiceSettlement:
purpose=invoice.purpose,
api_key_hash=invoice.api_key_hash,
mint_url=invoice.mint_url,
balance_limit=invoice.balance_limit,
balance_limit_reset=invoice.balance_limit_reset,
validity_date=invoice.validity_date,
)
@@ -114,8 +110,6 @@ class InvoiceCreateRequest(BaseModel):
default=None,
description="Deprecated: legacy field for topup. Prefer Authorization header.",
)
balance_limit: int | None = Field(default=None)
balance_limit_reset: str | None = Field(default=None)
validity_date: int | None = Field(default=None)
@@ -312,8 +306,6 @@ async def create_invoice(
api_key_hash=api_key_token[3:] if api_key_token else None,
purpose=request.purpose,
mint_url=mint_url,
balance_limit=request.balance_limit,
balance_limit_reset=request.balance_limit_reset,
validity_date=request.validity_date,
expires_at=expires_at,
)
@@ -697,8 +689,6 @@ async def _create_api_key_record(
balance=invoice.amount_sats * 1000,
refund_currency="sat",
refund_mint_url=mint_url,
balance_limit=invoice.balance_limit,
balance_limit_reset=invoice.balance_limit_reset,
validity_date=invoice.validity_date,
)
session.add(api_key)
+6 -11
View File
@@ -18,7 +18,6 @@ from ..auth import (
_claim_reservation_for_charge,
_stop_reservation_heartbeat,
_validate_reservation_snapshot,
get_billing_key,
get_reservation_snapshot,
payments_logger,
release_reservation,
@@ -527,9 +526,8 @@ async def finalize_ehbp_actual_cost_payment(
if not await _claim_reservation_for_charge(reservation, session):
return 0
reserved_cost_for_model = reservation.reserved_msats
billing_key = await get_billing_key(key, session)
key_hash = key.hashed_key
billing_key_hash = billing_key.hashed_key
billing_key_hash = key_hash
total_cost_msats = max(
0, int(cost_info.get("total_msats", reserved_cost_for_model))
)
@@ -538,7 +536,6 @@ async def finalize_ehbp_actual_cost_payment(
charged = await _charge_reservation_rows(
session,
billing_key_hash=billing_key_hash,
key_hash=key_hash,
reserved_msats=reserved_cost_for_model,
charge_msats=total_cost_msats,
)
@@ -558,9 +555,7 @@ async def finalize_ehbp_actual_cost_payment(
await session.commit()
await _stop_reservation_heartbeat(reservation.release_id)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
await session.refresh(key)
if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0:
fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100)
@@ -577,15 +572,15 @@ async def finalize_ehbp_actual_cost_payment(
extra={
"event": "finalize",
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"billing_key_hash": key.hashed_key[:8] + "...",
"model": model_id,
"cost_reserved": reserved_cost_for_model,
"cost_charged": total_cost_msats,
"input_tokens": cost_info.get("input_tokens", 0),
"output_tokens": cost_info.get("output_tokens", 0),
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"total_spent": billing_key.total_spent,
"balance": key.balance,
"reserved_balance": key.reserved_balance,
"total_spent": key.total_spent,
"finalize_type": "ehbp_usage",
"finalized_at": now,
},
-190
View File
@@ -1,190 +0,0 @@
import asyncio
import secrets
from typing import Any
import pytest
from fastapi import HTTPException
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import adjust_payment_for_tokens, pay_for_request
from routstr.balance import ChildKeyRequest, create_child_key
from routstr.core.db import ApiKey, create_session
from routstr.core.settings import settings
@pytest.mark.asyncio
async def test_child_key_flow(integration_session: AsyncSession) -> None:
# 1. Create a parent key with balance
parent_raw = "parent_test_key_" + secrets.token_hex(4)
parent_key = ApiKey(
hashed_key=parent_raw,
balance=10000, # 10 sats
)
integration_session.add(parent_key)
await integration_session.commit()
await integration_session.refresh(parent_key)
# Mock settings
settings.child_key_cost = 1000 # 1 sat
# 2. Call create_child_key
result = await create_child_key(
ChildKeyRequest(count=1), parent_key, integration_session
)
assert "api_keys" in result
assert result["cost_msats"] == 1000
assert result["parent_balance"] == 9000
child_key_raw = result["api_keys"][0][3:] # remove sk-
# 3. Verify child key exists in DB
child_key_db = await integration_session.get(ApiKey, child_key_raw)
assert child_key_db is not None
assert child_key_db.parent_key_hash == parent_key.hashed_key
assert child_key_db.balance == 0
# 4. Test payment with child key
cost = 500
await pay_for_request(child_key_db, cost, integration_session)
# Refresh keys
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key_db)
# Parent should be charged
assert parent_key.reserved_balance == 500
assert parent_key.total_requests == 1
# Child should have total_requests incremented
assert child_key_db.total_requests == 1
# 5. Test adjustment
response_data = {"model": "test-model", "usage": {"total_tokens": 10}}
# Mock calculate_cost
import routstr.auth
from routstr.payment.cost_calculation import CostData
async def mock_calculate_cost(*args: Any, **kwargs: Any) -> CostData:
return CostData(
base_msats=0, input_msats=200, output_msats=200, total_msats=400
)
# Patch calculate_cost
original_calculate_cost = routstr.auth.calculate_cost
routstr.auth.calculate_cost = mock_calculate_cost
try:
adjustment = await adjust_payment_for_tokens(
child_key_db, response_data, integration_session, 500, None, None
)
assert adjustment["total_msats"] == 400
# Refresh keys
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key_db)
# Parent should have updated balance and total_spent
assert parent_key.reserved_balance == 0
assert parent_key.balance == 9000 - 400
assert (
parent_key.total_spent == 1400
) # 1000 for child key creation + 400 for request
# Child should also have total_spent updated
assert child_key_db.total_spent == 400
finally:
routstr.auth.calculate_cost = original_calculate_cost
@pytest.mark.asyncio
async def test_child_key_insufficient_balance(
integration_session: AsyncSession,
) -> None:
parent_key = ApiKey(
hashed_key="poor_parent_" + secrets.token_hex(4),
balance=500,
)
integration_session.add(parent_key)
await integration_session.commit()
await integration_session.refresh(parent_key)
settings.child_key_cost = 1000
with pytest.raises(HTTPException) as exc:
await create_child_key(
ChildKeyRequest(count=1), parent_key, integration_session
)
assert exc.value.status_code == 402
@pytest.mark.asyncio
async def test_concurrent_child_key_creation_is_atomic(
patched_db_engine: None,
) -> None:
"""Two concurrent create_child_key() calls with balance for exactly one must
result in exactly one success and one 402, with the parent balance deducted
only once."""
child_key_cost = 1000
settings.child_key_cost = child_key_cost
parent_hash = f"parent_concurrent_{secrets.token_hex(8)}"
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=child_key_cost)
session.add(parent)
await session.commit()
results: list[str] = []
async def attempt() -> None:
async with create_session() as session:
fresh_parent = await session.get(ApiKey, parent_hash)
assert fresh_parent is not None
try:
await create_child_key(ChildKeyRequest(count=1), fresh_parent, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt(), attempt())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final = await session.get(ApiKey, parent_hash)
assert final is not None
assert final.balance == 0, (
f"Balance should be fully deducted once: expected 0, got {final.balance}"
)
assert final.total_spent == child_key_cost, (
f"total_spent should equal one deduction: expected {child_key_cost}, "
f"got {final.total_spent}"
)
@pytest.mark.asyncio
async def test_child_key_cannot_create_child(integration_session: AsyncSession) -> None:
parent_key = ApiKey(
hashed_key="parent_" + secrets.token_hex(4),
balance=10000,
)
child_key = ApiKey(
hashed_key="child_" + secrets.token_hex(4),
balance=0,
parent_key_hash=parent_key.hashed_key,
)
integration_session.add(parent_key)
integration_session.add(child_key)
await integration_session.commit()
await integration_session.refresh(child_key)
with pytest.raises(HTTPException) as exc:
await create_child_key(ChildKeyRequest(count=1), child_key, integration_session)
assert exc.value.status_code == 400
assert "Cannot create a child key for another child key" in str(exc.value.detail)
-102
View File
@@ -1,102 +0,0 @@
from typing import Any
import pytest
from httpx import AsyncClient
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_info_returns_child_keys(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
integration_session: Any,
) -> None:
"""Test that GET /v1/wallet/info returns child keys for a parent key"""
# 1. Get parent info to find its hashed_key
response = await authenticated_client.get("/v1/wallet/info")
assert response.status_code == 200
parent_data = response.json()
parent_data["api_key"]
# 2. Create child keys for this parent
# We need to use the parent's authentication for this
child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"}
create_response = await authenticated_client.post(
"/v1/wallet/child-key", json=child_payload
)
assert create_response.status_code == 200
create_data = create_response.json()
child_keys = create_data["api_keys"]
assert len(child_keys) == 2
# 3. Call /info again and check for child_keys
info_response = await authenticated_client.get("/v1/wallet/info")
assert info_response.status_code == 200
info_data = info_response.json()
assert "child_keys" in info_data
assert len(info_data["child_keys"]) == 2
# Verify child key details
for ck in info_data["child_keys"]:
assert ck["api_key"] in child_keys
assert ck["balance_limit"] == 1000
assert ck["balance_limit_reset"] == "daily"
assert "total_spent" in ck
assert "total_requests" in ck
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_info_child_key_no_child_keys(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
integration_session: Any,
) -> None:
"""Test that GET /v1/wallet/info for a child key does NOT return child_keys"""
# 1. Create a child key
child_payload = {"count": 1}
create_response = await authenticated_client.post(
"/v1/wallet/child-key", json=child_payload
)
assert create_response.status_code == 200
child_key = create_response.json()["api_keys"][0]
# 2. Use the child key to get its info
integration_client.headers["Authorization"] = f"Bearer {child_key}"
info_response = await integration_client.get("/v1/wallet/info")
assert info_response.status_code == 200
info_data = info_response.json()
parent_key = authenticated_client._test_api_key # type: ignore[attr-defined]
parent_key_hash = parent_key.removeprefix("sk-")
assert info_data["is_child"] is True
assert "child_keys" not in info_data
assert "parent_key" not in info_data
assert info_data["parent_key_preview"] == parent_key_hash[:8] + "..."
assert info_data["parent_key_preview"] not in {parent_key, parent_key_hash}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_account_info_root_returns_child_keys(
authenticated_client: AsyncClient,
) -> None:
"""Test that GET / returns child keys for a parent key (root endpoint)"""
# 1. Create a child key
child_payload = {"count": 1}
await authenticated_client.post("/v1/wallet/child-key", json=child_payload)
# 2. Call root endpoint /v1/balance/
# Note: routstr/balance.py defines router = APIRouter()
# and it is included in balance_router with prefix /v1/balance
# The endpoint is @router.get("/")
response = await authenticated_client.get("/v1/balance/")
assert response.status_code == 200
data = response.json()
assert "child_keys" in data
assert len(data["child_keys"]) >= 1
+1 -98
View File
@@ -17,7 +17,7 @@ from httpx import AsyncClient
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ApiKey, ReservationRelease
from routstr.core.db import ReservationRelease
from routstr.payment.models import Architecture, Model, Pricing
from routstr.proxy import refresh_model_maps
from routstr.upstream.base import BaseUpstreamProvider
@@ -531,103 +531,6 @@ async def test_failover_beyond_balance_envelope_is_rejected(
# fallback must be rejected before its upstream is ever contacted.
assert response.status_code == 402
assert [r.url.host for r in sent_requests] == ["cheap.example.com"]
@pytest.fixture
async def three_candidate_child_maps(
patched_db_engine: None,
) -> AsyncGenerator[None, None]:
"""Second candidate cannot fit the child limit; third restores and serves."""
first = _StaticProvider(
CHEAP_BASE_URL,
"key-first",
1.0,
_make_model("dual-model", 0.001, 0.002, max_cost=50.0),
)
too_large = _StaticProvider(
EXPENSIVE_BASE_URL,
"key-too-large",
1.0,
_make_model("dual-model", 0.002, 0.003, max_cost=100.0),
)
third = _StaticProvider(
THIRD_BASE_URL,
"key-third",
1.0,
_make_model("dual-model", 0.003, 0.004, max_cost=50.0),
)
async for _ in _install_providers([first, too_large, third]):
yield
@pytest.mark.integration
@pytest.mark.asyncio
async def test_child_failover_rolls_back_failed_larger_reserve_before_restoring(
authenticated_client: AsyncClient,
three_candidate_child_maps: None,
integration_session: AsyncSession,
) -> None:
"""A failed child guard cannot leak its parent update into restoration."""
key_hash = authenticated_client._test_api_key.removeprefix("sk-") # type: ignore[attr-defined]
child = await integration_session.get(ApiKey, key_hash)
assert child is not None
parent = ApiKey(hashed_key="failover-parent", balance=10_000_000)
child.parent_key_hash = parent.hashed_key
child.balance_limit = 75_000
integration_session.add(parent)
integration_session.add(child)
await integration_session.commit()
sent_requests: list[httpx.Request] = []
async def fake_transport(
request: httpx.Request, *args: Any, **kwargs: Any
) -> httpx.Response:
sent_requests.append(request)
return _upstream_response(request)
with (
patch(
"httpx.AsyncHTTPTransport.handle_async_request",
side_effect=fake_transport,
),
patch(
"routstr.payment.cost_calculation.sats_usd_price",
return_value=0.0005,
),
):
response = await authenticated_client.post(
"/v1/chat/completions",
json={
"model": "dual-model",
"messages": [{"role": "user", "content": "hello"}],
},
)
assert response.status_code == 200
# The 100-sat candidate is rejected before forwarding; the third serves.
assert [request.url.host for request in sent_requests] == [
"cheap.example.com",
"third.example.com",
]
await integration_session.refresh(parent)
await integration_session.refresh(child)
assert parent.reserved_balance == 0
assert child.reserved_balance == 0
assert parent.total_spent == response.json()["cost"]["total_msats"]
records = (
await integration_session.exec(
select(ReservationRelease).where(ReservationRelease.key_hash == key_hash)
)
).all()
assert len(records) == 2
assert sorted(record.status for record in records) == ["charged", "released"]
assert len({record.reserved_msats for record in records}) == 1
assert all(record.status != "active" for record in records)
@pytest.fixture
async def raised_envelope_provider_maps(
patched_db_engine: None,
+1 -366
View File
@@ -1,14 +1,10 @@
import asyncio
import time
from datetime import datetime, timedelta
import pytest
from fastapi import HTTPException
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
from routstr.core.db import ApiKey, create_session
from routstr.core.db import ApiKey
@pytest.mark.asyncio
@@ -25,367 +21,6 @@ async def test_key_validity_date(integration_session: AsyncSession) -> None:
assert "expired" in str(excinfo.value).lower()
@pytest.mark.asyncio
async def test_key_balance_limit(integration_session: AsyncSession) -> None:
# 1. Create a key with a balance limit
key = ApiKey(
hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450
)
integration_session.add(key)
await integration_session.commit()
# 2. Try to pay for a request that exceeds the limit
with pytest.raises(Exception) as excinfo:
await pay_for_request(key, 100, integration_session)
assert "limit exceeded" in str(excinfo.value).lower()
# 3. Try to pay for a request that fits
await pay_for_request(key, 50, integration_session)
await integration_session.refresh(key)
# Note: total_spent is updated in adjust_payment_for_tokens,
# but pay_for_request checks it.
# In our current logic, pay_for_request checks (total_spent + cost) > balance_limit.
@pytest.mark.asyncio
async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None:
# 1. Create a key with a daily reset policy and old reset date
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
key = ApiKey(
hashed_key="daily_reset_key",
balance=10000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=900,
)
integration_session.add(key)
await integration_session.commit()
# 2. Pay for a request - should trigger reset first because it's a new day
# Request is 200, total_spent is 900. 900+200 > 1000,
# but reset should happen making total_spent 0, then 0+200 < 1000.
await pay_for_request(key, 200, integration_session)
await integration_session.refresh(key)
assert key.total_spent == 0 # Reset in pay_for_request happens before charging
# Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests,
# but total_spent is updated in adjust_payment_for_tokens.
# However, the reset logic sets total_spent to 0.
assert key.balance_limit_reset_date is not None
assert key.balance_limit_reset_date > yesterday
@pytest.mark.asyncio
async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None:
# 1. Create multiple keys needing reset
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
key1 = ApiKey(
hashed_key="job_reset_key_1",
balance=1000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=500,
)
key2 = ApiKey(
hashed_key="job_reset_key_2",
balance=1000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=800,
)
integration_session.add(key1)
integration_session.add(key2)
await integration_session.commit()
# 2. Run the periodic reset logic manually (mocking the background task loop)
# We can't easily run the actual loop because it has a sleep,
# but we can test the logic inside.
# Implementation of periodic_key_reset logic for testing:
stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) # noqa: E711
keys = (await integration_session.exec(stmt)).all()
now = int(time.time())
for k in keys:
if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]:
k.total_spent = 0
k.balance_limit_reset_date = now
integration_session.add(k)
await integration_session.commit()
# 3. Verify resets
await integration_session.refresh(key1)
await integration_session.refresh(key2)
assert key1.total_spent == 0
assert key2.total_spent == 0
@pytest.mark.asyncio
async def test_balance_limit_enforced_atomically_under_concurrency(
patched_db_engine: None,
) -> None:
parent_hash = "parent_limit_atomic"
child_hash = "child_limit_atomic"
cost = 300
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=10000)
child = ApiKey(
hashed_key=child_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=cost,
total_spent=0,
)
session.add(parent)
session.add(child)
await session.commit()
results: list[str] = []
async def attempt() -> None:
async with create_session() as session:
fresh_child = await session.get(ApiKey, child_hash)
assert fresh_child is not None
try:
await pay_for_request(fresh_child, cost, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt(), attempt())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final_child = await session.get(ApiKey, child_hash)
assert final_child is not None
assert final_child.reserved_balance == cost, (
f"Child reserved_balance should equal one reservation, "
f"got {final_child.reserved_balance}"
)
@pytest.mark.asyncio
async def test_parallel_payments_with_parent_and_child_key(
patched_db_engine: None,
) -> None:
parent_hash = "parent_parallel_mixed"
child_hash = "child_parallel_mixed"
cost = 300
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=10000)
child = ApiKey(
hashed_key=child_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=2 * cost,
)
session.add(parent)
session.add(child)
await session.commit()
async def attempt(key_hash: str) -> str:
async with create_session() as session:
fresh_key = await session.get(ApiKey, key_hash)
assert fresh_key is not None
try:
await pay_for_request(fresh_key, cost, session)
return "success"
except HTTPException as exc:
assert exc.status_code == 402
return "blocked"
results = await asyncio.gather(attempt(parent_hash), attempt(child_hash))
assert results == ["success", "success"], (
f"Both parent and child payments should succeed, got: {results}"
)
async with create_session() as session:
final_parent = await session.get(ApiKey, parent_hash)
final_child = await session.get(ApiKey, child_hash)
assert final_parent is not None
assert final_child is not None
# Both requests bill the parent; only the child request reserves on the child.
assert final_parent.reserved_balance == 2 * cost
assert final_parent.total_requests == 2
assert final_child.reserved_balance == cost
assert final_child.total_requests == 1
@pytest.mark.asyncio
async def test_balance_limit_with_existing_total_spent_under_concurrency(
patched_db_engine: None,
) -> None:
parent_hash = "parent_total_spent"
child_hash = "child_total_spent"
cost = 300
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=10000)
# 700 already spent against a 1000 limit: only one more 300 request fits.
child = ApiKey(
hashed_key=child_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=1000,
total_spent=700,
)
session.add(parent)
session.add(child)
await session.commit()
results: list[str] = []
async def attempt() -> None:
async with create_session() as session:
fresh_child = await session.get(ApiKey, child_hash)
assert fresh_child is not None
try:
await pay_for_request(fresh_child, cost, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt(), attempt())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final_child = await session.get(ApiKey, child_hash)
assert final_child is not None
assert final_child.reserved_balance == cost
assert final_child.total_spent == 700
@pytest.mark.asyncio
async def test_balance_limit_with_existing_reserved_balance(
patched_db_engine: None,
) -> None:
parent_hash = "parent_reserved_set"
blocked_hash = "child_reserved_blocked"
allowed_hash = "child_reserved_allowed"
cost = 300
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=10000)
# 800 already reserved against a 1000 limit: another 300 must be rejected.
blocked_child = ApiKey(
hashed_key=blocked_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=1000,
reserved_balance=800,
)
# 500 reserved against a 1000 limit: another 300 still fits.
allowed_child = ApiKey(
hashed_key=allowed_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=1000,
reserved_balance=500,
)
session.add(parent)
session.add(blocked_child)
session.add(allowed_child)
await session.commit()
async with create_session() as session:
fresh_blocked = await session.get(ApiKey, blocked_hash)
assert fresh_blocked is not None
with pytest.raises(HTTPException) as exc_info:
await pay_for_request(fresh_blocked, cost, session)
assert exc_info.value.status_code == 402
async with create_session() as session:
fresh_allowed = await session.get(ApiKey, allowed_hash)
assert fresh_allowed is not None
await pay_for_request(fresh_allowed, cost, session)
async with create_session() as session:
final_blocked = await session.get(ApiKey, blocked_hash)
final_allowed = await session.get(ApiKey, allowed_hash)
final_parent = await session.get(ApiKey, parent_hash)
assert final_blocked is not None
assert final_allowed is not None
assert final_parent is not None
assert final_blocked.reserved_balance == 800, "Rejected request must not reserve"
assert final_blocked.total_requests == 0
assert final_allowed.reserved_balance == 500 + cost
assert final_allowed.total_requests == 1
# Only the allowed request should have billed the parent.
assert final_parent.reserved_balance == cost
assert final_parent.total_requests == 1
@pytest.mark.asyncio
async def test_child_reservation_discarded_when_parent_balance_depleted(
patched_db_engine: None,
) -> None:
parent_hash = "parent_depleted"
child_hash = "child_depleted"
cost = 300
async with create_session() as session:
# Parent can only afford one request; child has no balance_limit.
parent = ApiKey(hashed_key=parent_hash, balance=cost)
child = ApiKey(
hashed_key=child_hash,
balance=0,
parent_key_hash=parent_hash,
)
session.add(parent)
session.add(child)
await session.commit()
results: list[str] = []
async def attempt() -> None:
async with create_session() as session:
fresh_child = await session.get(ApiKey, child_hash)
assert fresh_child is not None
try:
await pay_for_request(fresh_child, cost, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt(), attempt())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final_parent = await session.get(ApiKey, parent_hash)
final_child = await session.get(ApiKey, child_hash)
assert final_parent is not None
assert final_child is not None
assert final_parent.reserved_balance == cost
# The failed request must not leave a committed reservation on the child.
assert final_child.reserved_balance == cost, (
f"Child reserved_balance should reflect only the successful request, "
f"got {final_child.reserved_balance}"
)
assert final_child.total_requests == 1
@pytest.mark.asyncio
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
# This requires mocking the router call or testing the logic in balance.py
@@ -1,10 +1,10 @@
"""Integration tests for Lightning invoice key constraint fields.
Covers two things:
- The three constraint fields (balance_limit, balance_limit_reset, validity_date)
are persisted on LightningInvoice and survive a DB round-trip.
- The production-path API-key record helper propagates those fields to the
created ApiKey, so the constraints are actually enforced when the key is used.
- The validity_date constraint field is persisted on LightningInvoice and
survives a DB round-trip.
- The production-path API-key record helper propagates it to the created
ApiKey, so the constraint is actually enforced when the key is used.
"""
from __future__ import annotations
@@ -67,32 +67,6 @@ def mock_wallet_mint() -> object:
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_invoice_persists_balance_limit(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice(balance_limit=5000)
integration_session.add(invoice)
await integration_session.commit()
stored = await integration_session.get(LightningInvoice, invoice.id)
assert stored is not None
assert stored.balance_limit == 5000
@pytest.mark.asyncio
async def test_invoice_persists_balance_limit_reset(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice(balance_limit=5000, balance_limit_reset="daily")
integration_session.add(invoice)
await integration_session.commit()
stored = await integration_session.get(LightningInvoice, invoice.id)
assert stored is not None
assert stored.balance_limit_reset == "daily"
@pytest.mark.asyncio
async def test_invoice_persists_validity_date(
integration_session: AsyncSession,
@@ -112,38 +86,6 @@ async def test_invoice_persists_validity_date(
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_created_key_receives_balance_limit(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice(balance_limit=8000)
integration_session.add(invoice)
await integration_session.flush()
api_key = await _create_api_key_record(invoice, integration_session)
await integration_session.commit()
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
assert stored_key is not None
assert stored_key.balance_limit == 8000
@pytest.mark.asyncio
async def test_created_key_receives_balance_limit_reset(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice(balance_limit=8000, balance_limit_reset="monthly")
integration_session.add(invoice)
await integration_session.flush()
api_key = await _create_api_key_record(invoice, integration_session)
await integration_session.commit()
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
assert stored_key is not None
assert stored_key.balance_limit_reset == "monthly"
@pytest.mark.asyncio
async def test_created_key_receives_validity_date(
integration_session: AsyncSession,
@@ -422,8 +364,6 @@ async def test_created_key_without_constraints_has_none_fields(
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
assert stored_key is not None
assert stored_key.balance_limit is None
assert stored_key.balance_limit_reset is None
assert stored_key.validity_date is None
+1 -98
View File
@@ -45,13 +45,7 @@ def _response() -> dict:
}
async def _new_key(
session: AsyncSession,
balance: int,
*,
parent_key_hash: str | None = None,
balance_limit: int | None = None,
) -> str:
async def _new_key(session: AsyncSession, balance: int) -> str:
key_hash = f"test_inv_{uuid.uuid4().hex}"
session.add(
ApiKey(
@@ -60,8 +54,6 @@ async def _new_key(
reserved_balance=0,
total_spent=0,
total_requests=0,
parent_key_hash=parent_key_hash,
balance_limit=balance_limit,
)
)
await session.commit()
@@ -458,92 +450,3 @@ async def test_release_after_charge_does_not_credit_the_user_back(
assert key.balance == 10_000 - cost
assert key.total_spent == cost
assert key.reserved_balance == 0
@pytest.mark.asyncio
async def test_child_request_spends_parent_balance_and_records_child_ledger(
integration_session: AsyncSession,
) -> None:
from routstr.auth import (
adjust_payment_for_tokens,
get_reservation_snapshot,
pay_for_request,
)
cost = 3_000
parent_hash = await _new_key(integration_session, balance=10_000)
child_hash = await _new_key(
integration_session, balance=0, parent_key_hash=parent_hash
)
child = await integration_session.get(ApiKey, child_hash)
assert child is not None
await pay_for_request(child, cost, integration_session)
reservation = await get_reservation_snapshot(child, integration_session)
with patch("routstr.auth.calculate_cost", return_value=_cost_data(cost)):
await adjust_payment_for_tokens(
child,
_response(),
integration_session,
cost,
reservation_snapshot=reservation,
)
parent = await integration_session.get(ApiKey, parent_hash)
child = await integration_session.get(ApiKey, child_hash)
assert parent is not None and child is not None
assert parent.balance == 10_000 - cost
assert parent.reserved_balance == 0
assert parent.total_balance >= 0
assert child.reserved_balance == 0, "child reservation must be released too"
assert child.total_balance >= 0
assert child.total_spent == cost, "child ledger must record the spend"
assert parent.total_spent == cost
@pytest.mark.asyncio
async def test_child_overrun_does_not_raid_a_sibling_reservation(
integration_session: AsyncSession,
) -> None:
"""Same overrun defect as the parent case, reached through a child key."""
from routstr.auth import (
adjust_payment_for_tokens,
get_reservation_snapshot,
pay_for_request,
)
reserved_each = 100
overrun = 150
parent_hash = await _new_key(integration_session, balance=2 * reserved_each)
child_a = await _new_key(
integration_session, balance=0, parent_key_hash=parent_hash
)
child_b = await _new_key(
integration_session, balance=0, parent_key_hash=parent_hash
)
key_a = await integration_session.get(ApiKey, child_a)
key_b = await integration_session.get(ApiKey, child_b)
assert key_a is not None and key_b is not None
await pay_for_request(key_a, reserved_each, integration_session)
reservation_a = await get_reservation_snapshot(key_a, integration_session)
await pay_for_request(key_b, reserved_each, integration_session)
await get_reservation_snapshot(key_b, integration_session)
with patch("routstr.auth.calculate_cost", return_value=_cost_data(overrun)):
await adjust_payment_for_tokens(
key_a,
_response(),
integration_session,
reserved_each,
reservation_snapshot=reservation_a,
)
parent = await integration_session.get(ApiKey, parent_hash)
assert parent is not None
assert parent.total_balance >= 0, (
f"child A's overrun ate child B's reservation: balance={parent.balance} "
f"reserved={parent.reserved_balance}"
)
@@ -98,34 +98,6 @@ async def test_used_key_never_pruned(patched_db_engine: None) -> None:
assert await _exists(k.hashed_key)
@pytest.mark.asyncio
async def test_parent_and_child_keys_are_not_pruned(
patched_db_engine: None,
) -> None:
"""Pruning must not orphan child keys or delete valid children."""
parent = _dead_key(LONG_AGO)
child = ApiKey(
hashed_key=f"child_{uuid.uuid4().hex}",
balance=0,
reserved_balance=0,
total_spent=0,
total_requests=0,
created_at=LONG_AGO,
parent_key_hash=parent.hashed_key,
)
async with create_session() as session:
session.add(parent)
session.add(child)
await session.commit()
async with create_session() as session:
pruned = await prune_dead_api_keys(session, OLD)
assert pruned == 0
assert await _exists(parent.hashed_key)
assert await _exists(child.hashed_key)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "expires_at"),
@@ -170,48 +170,6 @@ async def test_revert_with_zero_reserved_balance_repairs_terminally(
assert updated.total_requests == 0
@pytest.mark.asyncio
async def test_child_corrupt_revert_clamps_zero_request_counts(
integration_session: AsyncSession,
) -> None:
from routstr.auth import (
get_reservation_snapshot,
pay_for_request,
revert_pay_for_request,
)
from routstr.core.db import ReservationRelease
suffix = uuid.uuid4().hex[:8]
parent = ApiKey(hashed_key=f"repair-parent-{suffix}", balance=5_000)
child = ApiKey(
hashed_key=f"repair-child-{suffix}",
parent_key_hash=parent.hashed_key,
)
integration_session.add(parent)
integration_session.add(child)
await integration_session.commit()
await pay_for_request(child, 500, integration_session)
snapshot = await get_reservation_snapshot(child, integration_session)
parent.total_requests = 0
child.total_requests = 0
child.reserved_balance = 0
integration_session.add(parent)
integration_session.add(child)
await integration_session.commit()
assert await revert_pay_for_request(child, integration_session, 500, snapshot)
integration_session.expunge_all()
parent_row = await integration_session.get(ApiKey, snapshot.billing_key_hash)
child_row = await integration_session.get(ApiKey, snapshot.key_hash)
release = await integration_session.get(ReservationRelease, snapshot.release_id)
assert parent_row is not None and child_row is not None
assert (parent_row.total_requests, child_row.total_requests) == (0, 0)
assert (parent_row.reserved_balance, child_row.reserved_balance) == (500, 0)
assert release is not None and release.status == "released"
@pytest.mark.asyncio
async def test_revert_with_sufficient_reserved_balance_succeeds(
integration_session: AsyncSession,
@@ -375,63 +333,3 @@ async def test_sequential_reverts_never_go_negative(
assert test_key.reserved_balance >= 0, (
f"Reserved balance went negative: {test_key.reserved_balance}"
)
@pytest.mark.asyncio
async def test_child_key_revert_floor_guard(
integration_session: AsyncSession,
) -> None:
"""Test that child key reserved_balance also has floor guard on revert."""
from routstr.auth import (
get_reservation_snapshot,
pay_for_request,
revert_pay_for_request,
)
parent_key_hash = f"test_parent_{uuid.uuid4().hex[:8]}"
child_key_hash = f"test_child_{uuid.uuid4().hex[:8]}"
parent_key = ApiKey(
hashed_key=parent_key_hash,
balance=10000,
reserved_balance=0,
total_requests=2,
)
child_key = ApiKey(
hashed_key=child_key_hash,
balance=0,
reserved_balance=0,
total_requests=2,
parent_key_hash=parent_key_hash,
)
integration_session.add(parent_key)
integration_session.add(child_key)
await integration_session.commit()
await pay_for_request(child_key, 500, integration_session)
snapshot = await get_reservation_snapshot(child_key, integration_session)
# First revert succeeds
result1 = await revert_pay_for_request(
child_key, integration_session, 500, snapshot
)
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key)
assert result1 is True
assert parent_key.reserved_balance == 0
assert child_key.reserved_balance == 0
# Second revert is a no-op for both parent and child
result2 = await revert_pay_for_request(
child_key, integration_session, 500, snapshot
)
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key)
assert result2 is False
assert parent_key.reserved_balance == 0, (
f"Parent reserved_balance should stay 0, got: {parent_key.reserved_balance}"
)
assert child_key.reserved_balance == 0, (
f"Child reserved_balance should stay 0, got: {child_key.reserved_balance}"
)
@@ -26,7 +26,6 @@ async def _add_key(
total_spent: int = 0,
total_requests: int = 0,
created_at: int | None = None,
parent_key_hash: str | None = None,
refund_address: str | None = None,
) -> ApiKey:
key = ApiKey(
@@ -35,7 +34,6 @@ async def _add_key(
reserved_balance=reserved_balance,
total_spent=total_spent,
total_requests=total_requests,
parent_key_hash=parent_key_hash,
refund_address=refund_address,
)
key.created_at = created_at
@@ -125,7 +123,7 @@ async def test_temporary_balances_pagination(
@pytest.mark.integration
@pytest.mark.asyncio
async def test_temporary_balances_totals_exclude_child_balance(
async def test_temporary_balances_totals(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
@@ -137,17 +135,6 @@ async def test_temporary_balances_totals_exclude_child_balance(
total_requests=3,
created_at=1000,
)
# Child draws from parent's balance, so its balance must NOT be summed,
# but its spent/requests still count.
await _add_key(
integration_session,
"child",
balance=0,
total_spent=200,
total_requests=7,
created_at=1001,
parent_key_hash="parent",
)
response = await integration_client.get(
"/admin/api/temporary-balances", headers=_admin_headers()
@@ -155,43 +142,8 @@ async def test_temporary_balances_totals_exclude_child_balance(
totals = response.json()["totals"]
assert totals["total_balance"] == 5000
assert totals["total_spent"] == 300
assert totals["total_requests"] == 10
@pytest.mark.integration
@pytest.mark.asyncio
async def test_child_reservations_are_not_double_counted(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
await _add_key(
integration_session,
"parent",
balance=5_000,
reserved_balance=700,
created_at=1_000,
)
await _add_key(
integration_session,
"child",
reserved_balance=700,
parent_key_hash="parent",
created_at=1_001,
)
response = await integration_client.get(
"/admin/api/temporary-balances", headers=_admin_headers()
)
body = response.json()
rows = {row["hashed_key"]: row for row in body["balances"]}
assert rows["parent"]["available_balance"] == 4_300
assert rows["parent"]["reserved_balance"] == 700
assert rows["child"]["available_balance"] is None
assert rows["child"]["reserved_balance"] == 700
assert body["totals"]["total_reserved_balance"] == 700
assert body["totals"]["total_available_balance"] == 4_300
assert totals["total_spent"] == 100
assert totals["total_requests"] == 3
@pytest.mark.integration
-15
View File
@@ -208,7 +208,6 @@ def _make_api_key(
refund_currency: str | None = "sat",
refund_mint_url: str | None = "https://mint.example.com",
refund_address: str | None = None,
parent_key_hash: str | None = None,
) -> ApiKey:
key = ApiKey(hashed_key="testhash")
key.balance = balance
@@ -216,7 +215,6 @@ def _make_api_key(
key.refund_currency = refund_currency
key.refund_mint_url = refund_mint_url
key.refund_address = refund_address
key.parent_key_hash = parent_key_hash
key.total_spent = 0
key.total_requests = 0
return key
@@ -306,7 +304,6 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
session.commit = AsyncMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store,
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -341,7 +338,6 @@ async def test_apikey_refund_logs_token() -> None:
session.commit = AsyncMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -370,7 +366,6 @@ async def test_apikey_refund_log_includes_path() -> None:
session.commit = AsyncMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -409,7 +404,6 @@ async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted")
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", mock_send_token),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -470,7 +464,6 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
session.commit = AsyncMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.send_token",
AsyncMock(side_effect=MintConnectionError("raw mint outage detail")),
@@ -508,7 +501,6 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
session.commit = AsyncMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(side_effect=RuntimeError(raw_error))),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -609,7 +601,6 @@ async def test_topup_mint_unreachable_returns_503(error: Exception) -> None:
session = MagicMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
):
with pytest.raises(HTTPException) as exc_info:
@@ -635,7 +626,6 @@ async def test_topup_unreachable_source_mint_explains_why_fallback_is_impossible
error = SourceMintConnectionError("Issuing Cashu mint is unreachable")
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
):
with pytest.raises(HTTPException) as exc_info:
@@ -660,7 +650,6 @@ async def test_topup_already_spent_still_returns_400() -> None:
session = MagicMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.credit_balance",
AsyncMock(side_effect=ValueError("Token already spent")),
@@ -688,7 +677,6 @@ async def test_topup_zero_value_returns_400_zero_value_message() -> None:
session = MagicMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.credit_balance",
AsyncMock(
@@ -720,7 +708,6 @@ async def test_topup_token_consumed_returns_500() -> None:
session = MagicMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.credit_balance",
AsyncMock(side_effect=TokenConsumedError("credit failed")),
@@ -786,7 +773,6 @@ async def test_topup_fee_and_swap_failures_return_422(
session = MagicMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
):
with pytest.raises(HTTPException) as exc_info:
@@ -811,7 +797,6 @@ async def test_topup_unexpected_non_valueerror_returns_500() -> None:
session = MagicMock()
with (
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.credit_balance",
AsyncMock(side_effect=RuntimeError("db exploded")),
+24 -93
View File
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, col, select, update
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
import routstr.auth as auth_module
@@ -107,19 +107,17 @@ async def test_finalize_actual_cost_payment_updates_balance_and_releases_reserve
@pytest.mark.asyncio
async def test_unmeasured_ehbp_releases_parent_and_child_reservation(
async def test_unmeasured_ehbp_releases_reservation(
session: AsyncSession,
) -> None:
parent = ApiKey(hashed_key="ehbp-parent", balance=10_000)
child = ApiKey(hashed_key="ehbp-child", balance=0, parent_key_hash="ehbp-parent")
session.add(parent)
session.add(child)
key = ApiKey(hashed_key="ehbp-key", balance=10_000)
session.add(key)
await session.commit()
await pay_for_request(child, 3_000, session)
reservation = await get_reservation_snapshot(child, session)
await pay_for_request(key, 3_000, session)
reservation = await get_reservation_snapshot(key, session)
charged = await finalize_ehbp_max_cost_payment(
child,
key,
session,
max_cost_for_model=3_000,
model_id="tinfoil/model",
@@ -127,18 +125,12 @@ async def test_unmeasured_ehbp_releases_parent_and_child_reservation(
)
assert charged == 0
updated_parent = await _api_key(session, "ehbp-parent")
updated_child = await _api_key(session, "ehbp-child")
assert updated_parent is not None
assert updated_child is not None
assert updated_parent.balance == 10_000
assert updated_parent.reserved_balance == 0
assert updated_parent.reserved_at is None
assert updated_parent.total_spent == 0
assert updated_child.balance == 0
assert updated_child.reserved_balance == 0
assert updated_child.reserved_at is None
assert updated_child.total_spent == 0
updated = await _api_key(session, "ehbp-key")
assert updated is not None
assert updated.balance == 10_000
assert updated.reserved_balance == 0
assert updated.reserved_at is None
assert updated.total_spent == 0
@pytest.mark.asyncio
@@ -182,21 +174,15 @@ async def test_unmeasured_ehbp_release_is_safe_when_charge_update_would_fail(
session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
parent = ApiKey(hashed_key="ehbp-rollback-parent", balance=10_000)
child = ApiKey(
hashed_key="ehbp-missing-child",
balance=0,
parent_key_hash="ehbp-rollback-parent",
)
session.add(parent)
session.add(child)
key = ApiKey(hashed_key="ehbp-rollback-key", balance=10_000)
session.add(key)
await session.commit()
await pay_for_request(child, 3_000, session)
reservation = await get_reservation_snapshot(child, session)
_fail_nth_api_key_update(session, monkeypatch, target_update=2)
await pay_for_request(key, 3_000, session)
reservation = await get_reservation_snapshot(key, session)
_fail_nth_api_key_update(session, monkeypatch, target_update=1)
charged = await finalize_ehbp_max_cost_payment(
child,
key,
session,
max_cost_for_model=3_000,
model_id="tinfoil/model",
@@ -204,68 +190,13 @@ async def test_unmeasured_ehbp_release_is_safe_when_charge_update_would_fail(
)
assert charged == 0
updated_parent = await _api_key(session, "ehbp-rollback-parent")
assert updated_parent is not None
assert updated_parent.balance == 10_000
updated = await _api_key(session, "ehbp-rollback-key")
assert updated is not None
assert updated.balance == 10_000
# The injected partial-update failure rolls aggregate subtraction back;
# terminal fencing prevents a charge or retry from consuming those funds.
assert updated_parent.reserved_balance == 3_000
assert updated_parent.total_spent == 0
updated_child = await _api_key(session, "ehbp-missing-child")
assert updated_child is not None
assert updated_child.reserved_balance == 3_000
assert updated_child.total_spent == 0
release = await session.get(ReservationRelease, reservation.release_id)
assert release is not None and release.status == "released"
assert reservation.release_id not in auth_module._reservation_heartbeats
@pytest.mark.asyncio
async def test_corrupt_child_aggregate_does_not_erase_parent_sibling_reserve(
session: AsyncSession,
) -> None:
parent = ApiKey(hashed_key="ehbp-corrupt-parent", balance=10_000)
child = ApiKey(
hashed_key="ehbp-corrupt-child",
balance=0,
parent_key_hash=parent.hashed_key,
)
session.add(parent)
session.add(child)
await session.commit()
await pay_for_request(child, 3_000, session)
reservation = await get_reservation_snapshot(child, session)
await session.exec( # type: ignore[call-overload]
update(ApiKey)
.where(col(ApiKey.hashed_key) == "ehbp-corrupt-parent")
.values(reserved_balance=5_000)
)
await session.exec( # type: ignore[call-overload]
update(ApiKey)
.where(col(ApiKey.hashed_key) == "ehbp-corrupt-child")
.values(reserved_balance=1_000)
)
await session.commit()
charged = await finalize_ehbp_actual_cost_payment(
child,
session,
reserved_cost_for_model=3_000,
model_id="tinfoil/model",
cost_info={"total_msats": 1_200},
reservation_snapshot=reservation,
)
assert charged == 0
updated_parent = await _api_key(session, "ehbp-corrupt-parent")
updated_child = await _api_key(session, "ehbp-corrupt-child")
assert updated_parent is not None and updated_child is not None
assert updated_parent.balance == 10_000
assert updated_parent.reserved_balance == 5_000
assert updated_parent.total_spent == 0
assert updated_child.reserved_balance == 1_000
assert updated_child.total_spent == 0
assert updated.reserved_balance == 3_000
assert updated.total_spent == 0
release = await session.get(ReservationRelease, reservation.release_id)
assert release is not None and release.status == "released"
assert reservation.release_id not in auth_module._reservation_heartbeats
+1 -55
View File
@@ -16,14 +16,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, select
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
from routstr.balance import refund_wallet_endpoint
from routstr.core.db import (
ApiKey,
ReservationRelease,
release_stale_reservations,
reset_all_reserved_balances,
)
@@ -70,26 +69,6 @@ async def test_pay_for_request_sets_reserved_at(session: AsyncSession) -> None:
assert key.reserved_at >= before
@pytest.mark.asyncio
async def test_pay_for_request_sets_reserved_at_on_child_key(
session: AsyncSession,
) -> None:
parent = ApiKey(hashed_key="parentkey", balance=10_000)
child = ApiKey(hashed_key="childkey", balance=0, parent_key_hash="parentkey")
session.add(parent)
session.add(child)
await session.commit()
await pay_for_request(child, 1_000, session)
await session.refresh(parent)
await session.refresh(child)
assert parent.reserved_balance == 1_000
assert parent.reserved_at is not None
assert child.reserved_balance == 1_000
assert child.reserved_at is not None
@pytest.mark.asyncio
async def test_revert_clears_reserved_at_when_fully_released(
session: AsyncSession,
@@ -153,39 +132,6 @@ async def test_release_stale_reservations_releases_old(session: AsyncSession) ->
assert key.reserved_at is None
@pytest.mark.asyncio
async def test_targeted_parent_cleanup_releases_child_owned_reservation(
session: AsyncSession,
) -> None:
parent = ApiKey(hashed_key="stale-parent", balance=5_000)
child = ApiKey(
hashed_key="stale-child", parent_key_hash=parent.hashed_key, balance=0
)
session.add_all([parent, child])
await session.commit()
await pay_for_request(child, 1_000, session)
reservation = (
await session.exec(
select(ReservationRelease).where(
ReservationRelease.key_hash == child.hashed_key
)
)
).one()
reservation.created_at = int(time.time()) - 1_000
session.add(reservation)
await session.commit()
released = await release_stale_reservations(
session, max_age_seconds=300, key_hash=parent.hashed_key
)
assert released == 1
await session.refresh(parent)
await session.refresh(child)
assert parent.reserved_balance == 0
assert child.reserved_balance == 0
@pytest.mark.asyncio
async def test_release_stale_reservations_keeps_fresh(session: AsyncSession) -> None:
key = ApiKey(
@@ -82,47 +82,42 @@ async def test_release_only_owns_its_concurrent_reservation() -> None:
@pytest.mark.asyncio
async def test_release_updates_parent_and_child_atomically() -> None:
async def test_release_clears_reservation_aggregates_atomically() -> None:
engine = await _engine()
parent = ApiKey(hashed_key="parent", balance=1_000)
child = ApiKey(hashed_key="child", parent_key_hash="parent", balance=0)
key = ApiKey(hashed_key="key", balance=1_000)
async with AsyncSession(engine, expire_on_commit=False) as session:
session.add_all([parent, child])
session.add(key)
await session.commit()
await pay_for_request(child, 500, session)
snapshot = await get_reservation_snapshot(child, session)
await pay_for_request(key, 500, session)
snapshot = await get_reservation_snapshot(key, session)
assert await release_reservation(snapshot, session, 500) is True
await session.refresh(parent)
await session.refresh(child)
assert (parent.reserved_balance, child.reserved_balance) == (0, 0)
assert (parent.reserved_at, child.reserved_at) == (None, None)
await session.refresh(key)
assert (key.reserved_balance, key.reserved_at) == (0, None)
await engine.dispose()
@pytest.mark.asyncio
async def test_release_repairs_partial_parent_child_corruption() -> None:
"""A child aggregate that no longer holds the reservation must not leave
async def test_release_repairs_partial_aggregate_corruption() -> None:
"""An aggregate that no longer holds the reservation must not leave
the durable row active forever: the release rolls the subtraction back and
terminalizes the reservation without touching aggregates."""
engine = await _engine()
parent = ApiKey(hashed_key="parent", balance=1_000)
child = ApiKey(hashed_key="child", parent_key_hash="parent", balance=0)
key = ApiKey(hashed_key="key", balance=1_000)
async with AsyncSession(engine, expire_on_commit=False) as session:
session.add_all([parent, child])
session.add(key)
await session.commit()
await pay_for_request(child, 500, session)
snapshot = await get_reservation_snapshot(child, session)
child.reserved_balance = 100
session.add(child)
await pay_for_request(key, 500, session)
snapshot = await get_reservation_snapshot(key, session)
key.reserved_balance = 100
session.add(key)
await session.commit()
assert await release_reservation(snapshot, session, 500) is True
await session.refresh(parent)
await session.refresh(child)
await session.refresh(key)
record = await session.get(ReservationRelease, snapshot.release_id)
# Aggregates untouched — legacy cleanup reconciles them when stale.
assert (parent.reserved_balance, child.reserved_balance) == (500, 100)
assert key.reserved_balance == 100
assert record is not None and record.status == "released"
await engine.dispose()
-481
View File
@@ -1,481 +0,0 @@
'use client';
import { useState } from 'react';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { useWalletInfo } from '@/hooks/use-wallet-info';
import { WalletService } from '@/lib/api/services/wallet';
import { ApiKeyInput } from './api-key-input';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Key, Copy, Check, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { KeyOptions } from './key-options';
interface KeyConfig {
id: string;
count: number;
balanceLimit: string;
balanceLimitReset: string;
validityDate: string;
}
interface ChildKeyCreatorProps {
baseUrl?: string;
apiKey?: string;
onApiKeyChange?: (apiKey: string) => void;
costPerKeyMsats?: number;
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
export function ChildKeyCreator({
baseUrl,
apiKey: propApiKey,
onApiKeyChange,
costPerKeyMsats,
}: ChildKeyCreatorProps) {
const [internalApiKey, setInternalApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [configs, setConfigs] = useState<KeyConfig[]>([
{
id: crypto.randomUUID(),
count: 1,
balanceLimit: '',
balanceLimitReset: '',
validityDate: '',
},
]);
const activeApiKey = propApiKey ?? internalApiKey;
const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey);
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const [newKeys, setNewKeys] = useState<string[]>([]);
const [resultInfo, setResultInfo] = useState<{
cost_msats: number;
parent_balance: number;
} | null>(null);
const { copiedKey, copy } = useCopyToClipboard();
const addConfig = () => {
setConfigs([
...configs,
{
id: crypto.randomUUID(),
count: 1,
balanceLimit: '',
balanceLimitReset: '',
validityDate: '',
},
]);
};
const removeConfig = (id: string) => {
if (configs.length > 1) {
setConfigs(configs.filter((c) => c.id !== id));
}
};
const updateConfig = (id: string, updates: Partial<KeyConfig>) => {
setConfigs(configs.map((c) => (c.id === id ? { ...c, ...updates } : c)));
};
const handleCreateKey = async () => {
if (!activeApiKey && baseUrl) {
toast.error('Please provide a Parent API key first');
return;
}
setLoading(true);
setError(null);
try {
let allNewKeys: string[] = [];
let totalCost = 0;
let lastParentBalance = 0;
for (const config of configs) {
const requestedCount = Math.max(1, Math.min(50, Number(config.count)));
const result = await WalletService.createChildKey(
baseUrl,
activeApiKey,
requestedCount,
config.balanceLimit ? parseInt(config.balanceLimit) : undefined,
config.balanceLimitReset || undefined,
config.validityDate
? Math.floor(
new Date(config.validityDate + 'T23:59:59').getTime() / 1000
)
: undefined
);
if (result.api_keys) {
allNewKeys = [...allNewKeys, ...result.api_keys];
}
totalCost += result.cost_msats;
lastParentBalance = result.parent_balance;
}
setNewKeys(allNewKeys);
setResultInfo({
cost_msats: totalCost,
parent_balance: lastParentBalance,
});
toast.success(
`${allNewKeys.length} child API key${
allNewKeys.length > 1 ? 's' : ''
} created successfully`
);
} catch (error) {
console.error('Failed to create child key:', error);
let errorMessage =
error instanceof Error ? error.message : 'Failed to create child key';
try {
const parsed = JSON.parse(errorMessage);
errorMessage =
parsed.detail?.error?.message ||
(typeof parsed.detail === 'string' ? parsed.detail : errorMessage);
} catch {}
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const copyToClipboard = async (key: string) => {
if (await copy(key, key)) {
toast.success('API key copied to clipboard');
}
};
const copyAllToClipboard = async () => {
if (await copy(newKeys.join('\n'), 'all')) {
toast.success('All API keys copied to clipboard');
}
};
return (
<div className='space-y-6'>
<Card>
<CardHeader>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<CardTitle>Create Child API Key</CardTitle>
<CardDescription>
Generate secondary API keys that share your account balance.
</CardDescription>
</div>
{costPerKeyMsats !== undefined && (
<div className='text-right'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide'>
Unit Cost
</p>
<p className='text-primary text-sm font-bold'>
{costPerKeyMsats / 1000} sats
</p>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{baseUrl && (
<div className='space-y-2'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Parent API Key
</Label>
<div className='flex gap-2'>
<div className='flex-1'>
<ApiKeyInput
value={activeApiKey}
onApiKeyChange={handleApiKeyChange}
/>
</div>
<Button
variant='outline'
size='icon'
aria-label='Copy parent API key'
onClick={() => copyToClipboard(activeApiKey)}
disabled={!activeApiKey}
>
{copiedKey === activeApiKey ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
{walletInfo && (
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
</div>
)}
</div>
)}
<div className='flex flex-col gap-6'>
{configs.map((config) => (
<div
key={config.id}
className='bg-muted/30 relative space-y-4 rounded-lg border p-4 pt-6'
>
{configs.length > 1 && (
<Button
variant='ghost'
size='icon'
className='text-destructive hover:bg-destructive/10 hover:text-destructive absolute top-2 right-2 h-7 w-7'
onClick={() => removeConfig(config.id)}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
<div className='flex flex-col gap-4 sm:flex-row sm:items-end'>
<div className='w-full space-y-2 sm:w-32'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Number of keys
</Label>
<Input
type='number'
min={1}
max={50}
value={config.count}
onChange={(e) => {
const val = parseInt(e.target.value);
updateConfig(config.id, {
count: isNaN(val)
? 1
: Math.max(1, Math.min(50, val)),
});
}}
className='h-9'
/>
</div>
<div className='flex-1'>
<KeyOptions
balanceLimit={config.balanceLimit}
setBalanceLimit={(val) =>
updateConfig(config.id, { balanceLimit: val })
}
validityDate={config.validityDate}
setValidityDate={(val) =>
updateConfig(config.id, { validityDate: val })
}
balanceLimitReset={config.balanceLimitReset}
setBalanceLimitReset={(val) =>
updateConfig(config.id, { balanceLimitReset: val })
}
/>
</div>
</div>
</div>
))}
<div className='flex justify-center'>
<Button
variant='outline'
size='sm'
onClick={addConfig}
className='gap-2 border-dashed'
>
<Plus className='h-4 w-4' />
Add Another Configuration
</Button>
</div>
<div className='flex flex-wrap items-center justify-between gap-4'>
<div className='text-muted-foreground text-xs'>
{costPerKeyMsats && (
<p>
Total Cost:{' '}
<span className='text-foreground font-medium'>
{costPerKeyMsats *
configs.reduce(
(acc, c) => acc + Number(c.count),
0
)}{' '}
mSats
</span>
</p>
)}
</div>
<Button
onClick={handleCreateKey}
disabled={loading || (!!baseUrl && !activeApiKey)}
className='w-full min-w-[140px] sm:w-auto'
>
{loading ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
<>
<Key className='mr-2 h-4 w-4' />
Generate{' '}
{configs.reduce(
(acc, c) => acc + Number(c.count),
0
)}{' '}
Keys
</>
)}
</Button>
</div>
{error && (
<Alert variant='destructive'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<p className='text-muted-foreground text-xs'>
Each key creation has a small one-time fee.
</p>
</div>
{newKeys.length > 0 && (
<div className='mt-6 space-y-4'>
<Alert>
<AlertTitle>
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
Generated
</AlertTitle>
<AlertDescription>
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
You won&apos;t be able to see them again.
{resultInfo && (
<div className='mt-2 font-medium opacity-80'>
Total Cost: {resultInfo.cost_msats / 1000} sats | New
Balance: {resultInfo.parent_balance / 1000} sats
</div>
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs font-medium'>
Generated Keys ({newKeys.length})
</span>
{newKeys.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-7 text-[10px]'
onClick={copyAllToClipboard}
>
<Copy className='mr-1 h-3 w-3' />
Copy All
</Button>
)}
</div>
<div className='grid gap-2'>
{newKeys.map((key, index) => (
<div
key={index}
className='group relative flex items-center gap-2'
>
<code className='bg-muted/50 flex-1 rounded border p-2.5 font-mono text-[10px] break-all sm:text-xs'>
{key}
</code>
<Button
size='icon'
variant='ghost'
className='h-8 w-8 shrink-0'
onClick={() => copyToClipboard(key)}
>
{copiedKey === key ? (
<Check className='h-3.5 w-3.5' />
) : (
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
)}
</Button>
</div>
))}
</div>
</div>
{newKeys.length > 3 && (
<div className='space-y-2'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Bulk Export (All Keys)
</Label>
<div className='relative'>
<Textarea
readOnly
value={newKeys.join('\n')}
rows={Math.min(newKeys.length, 6)}
className='bg-muted/30 font-mono text-[10px] leading-relaxed'
/>
<Button
size='sm'
variant='secondary'
className='absolute right-2 bottom-2 h-7 text-[10px]'
onClick={copyAllToClipboard}
>
Copy Bulk
</Button>
</div>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+2 -63
View File
@@ -1,53 +1,15 @@
import { Zap, Calendar, Shield } from 'lucide-react';
import { Calendar } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface KeyOptionsProps {
balanceLimit: string;
setBalanceLimit: (val: string) => void;
validityDate: string;
setValidityDate: (val: string) => void;
balanceLimitReset: string;
setBalanceLimitReset: (val: string) => void;
showBalanceLimit?: boolean;
}
export function KeyOptions({
balanceLimit,
setBalanceLimit,
validityDate,
setValidityDate,
balanceLimitReset,
setBalanceLimitReset,
showBalanceLimit = true,
}: KeyOptionsProps) {
export function KeyOptions({ validityDate, setValidityDate }: KeyOptionsProps) {
return (
<div className='grid gap-4 sm:grid-cols-3'>
{showBalanceLimit && (
<div className='space-y-2'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Zap className='h-3 w-3' />
Balance Limit (mSats)
</Label>
<Input
type='number'
placeholder='No limit'
value={balanceLimit}
onChange={(e) => setBalanceLimit(e.target.value)}
className='h-9 text-xs'
name='balance_limit_msats'
autoComplete='off'
/>
</div>
)}
<div className='space-y-2'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Calendar className='h-3 w-3' />
@@ -61,29 +23,6 @@ export function KeyOptions({
name='validity_date'
/>
</div>
<div className='space-y-2'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Shield className='h-3 w-3' />
Reset Policy
</Label>
<Select
value={balanceLimitReset || 'none'}
onValueChange={(value) =>
setBalanceLimitReset(value === 'none' ? '' : value)
}
>
<SelectTrigger className='h-9 w-full text-xs'>
<SelectValue placeholder='None' />
</SelectTrigger>
<SelectContent>
<SelectItem value='none'>None</SelectItem>
<SelectItem value='daily'>Daily</SelectItem>
<SelectItem value='weekly'>Weekly</SelectItem>
<SelectItem value='monthly'>Monthly</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
@@ -53,8 +53,6 @@ export function CashuPaymentWorkflow({
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
const [validityDate, setValidityDate] = useState<string>('');
const [error, setError] = useState<string | null>(null);
@@ -90,15 +88,10 @@ export function CashuPaymentWorkflow({
try {
const requestPayload: {
initial_balance_token: string;
balance_limit?: number;
balance_limit_reset?: string;
validity_date?: number;
} = {
initial_balance_token: initialToken.trim(),
};
if (balanceLimit) requestPayload.balance_limit = Number(balanceLimit);
if (balanceLimitReset)
requestPayload.balance_limit_reset = balanceLimitReset;
if (validityDate) {
requestPayload.validity_date = Math.floor(
new Date(validityDate + 'T23:59:59').getTime() / 1000
@@ -116,24 +109,16 @@ export function CashuPaymentWorkflow({
const payload = (await response.json()) as {
api_key: string;
balance: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
};
const snapshot: WalletSnapshot = {
apiKey: payload.api_key,
balanceMsats: payload.balance ?? 0,
reservedMsats: 0,
isChild: payload.is_child ?? false,
parentKey: payload.parent_key ?? null,
totalRequests: payload.total_requests ?? 0,
totalSpent: payload.total_spent ?? 0,
balanceLimit: payload.balance_limit ?? null,
balanceLimitReset: payload.balance_limit_reset ?? null,
validityDate: payload.validity_date ?? null,
};
@@ -153,8 +138,6 @@ export function CashuPaymentWorkflow({
initialToken,
baseUrl,
onApiKeyCreated,
balanceLimit,
balanceLimitReset,
validityDate,
]);
@@ -252,13 +235,8 @@ export function CashuPaymentWorkflow({
/>
<div className='space-y-4'>
<KeyOptions
balanceLimit={balanceLimit}
setBalanceLimit={setBalanceLimit}
validityDate={validityDate}
setValidityDate={setValidityDate}
balanceLimitReset={balanceLimitReset}
setBalanceLimitReset={setBalanceLimitReset}
showBalanceLimit={false}
/>
<div className='flex flex-wrap items-center gap-3'>
+1 -12
View File
@@ -20,7 +20,6 @@ import {
} from './cashu-payment-workflow';
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
import { ChildKeyCreator } from '@/components/child-key-creator';
type NodeInfo = {
name?: string;
@@ -30,7 +29,6 @@ type NodeInfo = {
onion_url?: string;
npub?: string;
mints?: string[];
child_key_cost_msats?: number;
token?: string;
recipient?: string;
sats?: string;
@@ -387,10 +385,9 @@ export function CheatSheet(): JSX.Element {
</section>
<Tabs defaultValue='cashu' className='w-full'>
<TabsList className='grid w-full grid-cols-4'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
<TabsTrigger value='management'>Key Management</TabsTrigger>
</TabsList>
@@ -449,14 +446,6 @@ export function CheatSheet(): JSX.Element {
)}
</TabsContent>
<TabsContent value='child-keys' className='space-y-4'>
<ChildKeyCreator
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
onApiKeyChange={handleApiKeyChanged}
costPerKeyMsats={nodeInfo?.child_key_cost_msats}
/>
</TabsContent>
</Tabs>
</main>
</div>
+2 -182
View File
@@ -14,32 +14,16 @@ import {
CardDescription,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { WalletService } from '@/lib/api/services/wallet';
import React, { useState, useCallback, useEffect } from 'react';
import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react';
export type ChildKeyInfo = {
api_key: string;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
};
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
export type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
isChild: boolean;
parentKey: string | null;
totalRequests: number;
totalSpent: number;
balanceLimit: number | null;
balanceLimitReset: string | null;
validityDate: number | null;
childKeys?: ChildKeyInfo[];
};
interface KeyInfoDetailsProps {
@@ -61,7 +45,6 @@ export function KeyInfoDetails({
}: KeyInfoDetailsProps): React.ReactNode {
const { copy } = useCopyToClipboard();
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isResetting, setIsResetting] = useState<string | null>(null);
const [isRefunding, setIsRefunding] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -100,23 +83,6 @@ export function KeyInfoDetails({
}
};
const handleResetSpent = async (childKey: string) => {
if (!walletInfo || walletInfo.isChild) return;
setIsResetting(childKey);
try {
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
toast.success('Child key spent reset');
await refetch();
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to reset child key'
);
} finally {
setIsResetting(null);
}
};
const handleRefund = useCallback(async (): Promise<void> => {
if (!apiKeyInput) {
toast.error('Paste an API key first');
@@ -160,7 +126,7 @@ export function KeyInfoDetails({
<CardHeader className='space-y-1'>
<CardTitle className='text-xl'>Key Information</CardTitle>
<CardDescription>
Enter an API key to view its balance, consumption, and child keys.
Enter an API key to view its balance and consumption.
</CardDescription>
</CardHeader>
<CardContent>
@@ -207,32 +173,6 @@ export function KeyInfoDetails({
<CardTitle className='text-lg'>Status & Identity</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Type</span>
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
</Badge>
</div>
{walletInfo.parentKey && (
<div className='space-y-1'>
<span className='text-muted-foreground text-xs tracking-wider'>
Parent Key
</span>
<div className='flex items-center gap-2'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{walletInfo.parentKey}
</code>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(walletInfo.parentKey!)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Validity
@@ -278,130 +218,10 @@ export function KeyInfoDetails({
</p>
</div>
</div>
{walletInfo.balanceLimit !== null && (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spend Limit
</span>
<span className='font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceLimit)} sats
</span>
</div>
{walletInfo.balanceLimitReset && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Reset Policy
</span>
<Badge variant='outline' className='capitalize'>
{walletInfo.balanceLimitReset}
</Badge>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{!walletInfo.isChild &&
walletInfo.childKeys &&
walletInfo.childKeys.length > 0 && (
<Card>
<CardHeader>
<CardTitle className='text-lg'>
Child Keys ({walletInfo.childKeys.length})
</CardTitle>
<CardDescription>
Secondary keys using this account&apos;s balance
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{walletInfo.childKeys.map((ck) => (
<div
key={ck.api_key}
className='space-y-3 rounded-lg border p-4'
>
<div className='flex items-center justify-between gap-4'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{ck.api_key}
</code>
<div className='flex gap-1'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(ck.api_key)}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
className='text-destructive h-8 w-8'
title='Reset consumption'
disabled={isResetting === ck.api_key}
onClick={() => handleResetSpent(ck.api_key)}
>
{isResetting === ck.api_key ? (
<RefreshCcw className='h-4 w-4 animate-spin' />
) : (
<RotateCcw className='h-4 w-4' />
)}
</Button>
</div>
</div>
<div className='grid grid-cols-2 gap-4 text-xs sm:grid-cols-5'>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Requests
</p>
<p className='font-mono font-medium'>
{ck.total_requests}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Spent
</p>
<p className='font-mono font-medium'>
{formatSats(ck.total_spent)} sats
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Limit
</p>
<p className='font-mono font-medium'>
{ck.balance_limit
? `${formatSats(ck.balance_limit)} sats`
: 'None'}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Policy
</p>
<p className='font-medium capitalize'>
{ck.balance_limit_reset || 'None'}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Expires
</p>
<p className='font-medium'>
{formatDate(ck.validity_date)}
</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
<div className='flex justify-center gap-4'>
<Button
@@ -213,8 +213,6 @@ export function LightningPaymentWorkflow({
const [isTopupping, setIsTopupping] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
const [validityDate, setValidityDate] = useState<string>('');
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
@@ -289,16 +287,12 @@ export function LightningPaymentWorkflow({
const payload: {
amount_sats: number;
purpose: string;
balance_limit?: number;
balance_limit_reset?: string;
validity_date?: number;
} = {
amount_sats: amount,
purpose: 'create',
};
if (balanceLimit) payload.balance_limit = parseInt(balanceLimit);
if (balanceLimitReset) payload.balance_limit_reset = balanceLimitReset;
if (validityDate) {
payload.validity_date = Math.floor(
new Date(validityDate + 'T23:59:59').getTime() / 1000
@@ -331,12 +325,8 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
@@ -362,8 +352,6 @@ export function LightningPaymentWorkflow({
baseUrl,
pollInvoiceStatus,
onApiKeyCreated,
balanceLimit,
balanceLimitReset,
validityDate,
]);
@@ -412,12 +400,8 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
@@ -473,12 +457,8 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
@@ -531,13 +511,8 @@ export function LightningPaymentWorkflow({
/>
<div className='space-y-4'>
<KeyOptions
balanceLimit={balanceLimit}
setBalanceLimit={setBalanceLimit}
validityDate={validityDate}
setValidityDate={setValidityDate}
balanceLimitReset={balanceLimitReset}
setBalanceLimitReset={setBalanceLimitReset}
showBalanceLimit={false}
/>
<div className='space-y-3'>
+27 -65
View File
@@ -23,7 +23,6 @@ import {
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Empty,
@@ -285,52 +284,31 @@ export function TemporaryBalances({
</TableHeader>
<TableBody>
{rows.map((balance, index) => {
const isChild = Boolean(balance.parent_key_hash);
return (
<TableRow
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
key={`${balance.hashed_key}-${index}`}
className={cn(
balance.available_balance === 0 &&
!isChild &&
'opacity-60',
isChild && 'bg-muted/30'
balance.available_balance === 0 && 'opacity-60'
)}
>
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
<div className='flex items-center gap-2'>
{isChild && (
<Badge
variant='outline'
className='h-4 px-1 text-[10px] uppercase'
>
Child
</Badge>
)}
<span>{balance.hashed_key}</span>
</div>
<span>{balance.hashed_key}</span>
</TableCell>
<TableCell className='text-right font-mono'>
{isChild ? (
<span className='text-muted-foreground italic'>
(Parent)
</span>
) : (
<div>
<div>
<div>
{formatBalance(
balance.available_balance ??
balance.balance
)}
</div>
<div className='text-muted-foreground text-xs'>
{formatBalance(balance.balance)} raw
</div>
<div className='text-muted-foreground text-xs'>
{formatBalance(balance.reserved_balance)}{' '}
reserved
</div>
{formatBalance(
balance.available_balance ?? balance.balance
)}
</div>
)}
<div className='text-muted-foreground text-xs'>
{formatBalance(balance.balance)} raw
</div>
<div className='text-muted-foreground text-xs'>
{formatBalance(balance.reserved_balance)}{' '}
reserved
</div>
</div>
</TableCell>
<TableCell className='text-right font-mono'>
{formatBalance(balance.total_spent)}
@@ -367,15 +345,11 @@ export function TemporaryBalances({
<div className='space-y-2 md:hidden'>
{rows.map((balance, index) => {
const isChild = Boolean(balance.parent_key_hash);
return (
<Card
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
key={`${balance.hashed_key}-mobile-${index}`}
className={cn(
balance.available_balance === 0 &&
!isChild &&
'opacity-80',
isChild && 'bg-muted/30'
balance.available_balance === 0 && 'opacity-80'
)}
>
<CardHeader className='p-4 pb-2'>
@@ -383,14 +357,6 @@ export function TemporaryBalances({
<CardDescription className='font-mono text-xs break-all'>
{balance.hashed_key}
</CardDescription>
{isChild && (
<Badge
variant='outline'
className='h-4 px-1.5 text-[10px] uppercase'
>
Child
</Badge>
)}
</div>
</CardHeader>
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
@@ -399,21 +365,17 @@ export function TemporaryBalances({
Available
</p>
<p className='font-mono text-sm'>
{isChild
? '(Uses Parent)'
: formatBalance(
balance.available_balance ?? balance.balance
)}
{formatBalance(
balance.available_balance ?? balance.balance
)}
</p>
{!isChild && (
<div className='text-muted-foreground text-xs'>
<p>{formatBalance(balance.balance)} raw</p>
<p>
{formatBalance(balance.reserved_balance)}{' '}
reserved
</p>
</div>
)}
<div className='text-muted-foreground text-xs'>
<p>{formatBalance(balance.balance)} raw</p>
<p>
{formatBalance(balance.reserved_balance)}{' '}
reserved
</p>
</div>
</div>
<div>
<p className='text-muted-foreground text-xs'>
-5
View File
@@ -22,14 +22,9 @@ export function useWalletInfo(baseUrl: string, apiKey: string) {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
},
enabled: !!baseUrl && !!apiKey,
-1
View File
@@ -1088,7 +1088,6 @@ export const TemporaryBalanceSchema = z.object({
total_requests: z.number(),
refund_address: z.string().nullable(),
key_expiry_time: z.number().nullable(),
parent_key_hash: z.string().nullable().optional(),
created_at: z.number().nullable().optional(),
});
-88
View File
@@ -45,15 +45,6 @@ export interface WithdrawResponse {
mint_url: string;
}
export interface CreateChildKeyResponse {
api_keys: string[];
count: number;
cost_msats: number;
cost_sats: number;
parent_balance: number;
parent_balance_sats: number;
}
export class WalletService {
static async redeemToken(token: string): Promise<RedeemTokenResponse> {
try {
@@ -134,83 +125,4 @@ export class WalletService {
}
}
static async createChildKey(
baseUrl?: string,
apiKey?: string,
count: number = 1,
balanceLimit?: number,
balanceLimitReset?: string,
validityDate?: number
): Promise<CreateChildKeyResponse> {
try {
if (baseUrl && apiKey) {
const response = await fetch(`${baseUrl}/v1/balance/child-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
count,
balance_limit: balanceLimit,
balance_limit_reset: balanceLimitReset,
validity_date: validityDate,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to create child key');
}
return (await response.json()) as CreateChildKeyResponse;
}
return await apiClient.post<CreateChildKeyResponse>(
'/v1/balance/child-key',
{
count,
balance_limit: balanceLimit,
balance_limit_reset: balanceLimitReset,
validity_date: validityDate,
}
);
} catch (error) {
console.error('Error creating child key:', error);
throw error;
}
}
static async resetChildKeySpent(
baseUrl: string | undefined,
parentKey: string,
childKey: string
): Promise<{ success: boolean; message: string }> {
try {
const url = baseUrl
? `${baseUrl}/v1/balance/child-key/reset`
: '/v1/balance/child-key/reset';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${parentKey}`,
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ child_key: childKey }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to reset child key');
}
return await response.json();
} catch (error) {
console.error('Error resetting child key:', error);
throw error;
}
}
}