From 05115c3387ff5a318bdda2b212390b1da4ff9159 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 22 Apr 2026 21:50:22 +0200 Subject: [PATCH] add api-key history and fix race condition while topup --- ...7b8c9_add_api_key_to_cashu_transactions.py | 46 ++++++++ routstr/balance.py | 94 +++++++++++++-- routstr/core/admin.py | 1 + routstr/core/db.py | 8 ++ routstr/wallet.py | 16 +++ tests/integration/test_wallet_refund.py | 38 ++++++- tests/integration/test_wallet_topup.py | 12 +- tests/unit/test_balance.py | 107 ++++++++++++++++++ ui/app/transactions/page.tsx | 32 +++++- ui/lib/api/services/admin.ts | 1 + 10 files changed, 339 insertions(+), 16 deletions(-) create mode 100644 migrations/versions/d4e5f6a7b8c9_add_api_key_to_cashu_transactions.py diff --git a/migrations/versions/d4e5f6a7b8c9_add_api_key_to_cashu_transactions.py b/migrations/versions/d4e5f6a7b8c9_add_api_key_to_cashu_transactions.py new file mode 100644 index 00000000..840b1ad7 --- /dev/null +++ b/migrations/versions/d4e5f6a7b8c9_add_api_key_to_cashu_transactions.py @@ -0,0 +1,46 @@ +"""add api key link to cashu_transactions + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-04-20 00:00:00.000000 +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d4e5f6a7b8c9" +down_revision = "c3d4e5f6a7b8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col["name"] for col in inspector.get_columns("cashu_transactions")] + indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")} + + if "api_key_hashed_key" not in columns: + op.add_column( + "cashu_transactions", + sa.Column( + "api_key_hashed_key", + sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + ), + ) + + if "ix_cashu_transactions_api_key_hashed_key" not in indexes: + op.create_index( + "ix_cashu_transactions_api_key_hashed_key", + "cashu_transactions", + ["api_key_hashed_key"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions") + op.drop_column("cashu_transactions", "api_key_hashed_key") diff --git a/routstr/balance.py b/routstr/balance.py index ff892ad3..815c67bc 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -7,7 +7,7 @@ from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel -from sqlmodel import select +from sqlmodel import col, select, update from .auth import get_billing_key, validate_bearer_key from .core.db import ( @@ -211,6 +211,26 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None: _refund_cache[key] = (expiry, value) +async def _restore_balance( + session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int +) -> None: + """Restore balance after a failed refund mint attempt.""" + restore_stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == hashed_key) + .values( + balance=col(ApiKey.balance) + balance, + reserved_balance=col(ApiKey.reserved_balance) + reserved_balance, + ) + ) + await session.exec(restore_stmt) # type: ignore[call-overload] + await session.commit() + logger.info( + "refund_wallet_endpoint: balance restored after mint failure", + extra={"hashed_key": hashed_key, "restored_balance": balance}, + ) + + @router.post("/refund", response_model=None) async def refund_wallet_endpoint( authorization: Annotated[str | None, Header()] = None, @@ -292,7 +312,27 @@ async def refund_wallet_endpoint( elif remaining_balance <= 0: raise HTTPException(status_code=400, detail="No balance to refund") - # Perform refund operation first, before modifying balance + # --- DEBIT FIRST: atomically zero the balance before minting tokens --- + # This prevents the race where a concurrent topup/spend happens between + # reading the balance and minting the refund token (double-spend). + debit_stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == key.hashed_key) + .where(col(ApiKey.balance) == key.balance) + .where(col(ApiKey.reserved_balance) == key.reserved_balance) + .values(balance=0, reserved_balance=0) + ) + debit_result = await session.exec(debit_stmt) # type: ignore[call-overload] + await session.commit() + + if debit_result.rowcount == 0: + # Balance changed between read and debit — another request is active + raise HTTPException( + status_code=409, + detail="Balance changed concurrently. Please retry the refund.", + ) + + # --- MINT: balance is locked at zero, safe to create the refund token --- try: if key.refund_address: from .core.settings import settings as global_settings @@ -328,10 +368,12 @@ async def refund_wallet_endpoint( ) except HTTPException: - # Re-raise HTTP exceptions (like 400 for balance too small) + # Minting failed — restore the debited balance + await _restore_balance(session, key.hashed_key, key.balance, key.reserved_balance) raise except Exception as e: - # If refund fails, don't modify the database + # Minting failed — restore the debited balance + await _restore_balance(session, key.hashed_key, key.balance, key.reserved_balance) error_msg = str(e) if ( "mint" in error_msg.lower() @@ -345,12 +387,6 @@ async def refund_wallet_endpoint( await _refund_cache_set(bearer_value, result) - previous_reserved_balance = key.reserved_balance - key.balance = 0 - key.reserved_balance = 0 - session.add(key) - await session.commit() - if "token" in result: try: await store_cashu_transaction( @@ -361,6 +397,7 @@ async def refund_wallet_endpoint( typ="out", collected=False, source="apikey", + api_key_hashed_key=key.hashed_key, ) except Exception: pass # store_cashu_transaction already logs @@ -369,13 +406,48 @@ async def refund_wallet_endpoint( "refund_wallet_endpoint: refund successful", extra={ "refunded_msats": remaining_balance_msats, - "previous_reserved_balance": previous_reserved_balance, + "previous_reserved_balance": key.reserved_balance, }, ) return result +@router.get("/history") +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) + .order_by(col(CashuTransaction.created_at).desc()) + ) + transactions = result.all() + return { + "transactions": [ + { + "id": tx.id, + "type": tx.type, + "source": tx.source, + "amount": tx.amount, + "unit": tx.unit, + "mint_url": tx.mint_url, + "created_at": tx.created_at, + "collected": tx.collected, + "swept": tx.swept, + } + for tx in transactions + ] + } + + @router.post("/donate") async def donate(token: str, ref: str | None = None) -> str: try: diff --git a/routstr/core/admin.py b/routstr/core/admin.py index d87120e2..e8eebfcf 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1360,6 +1360,7 @@ async def get_transactions_api( (col(CashuTransaction.id).like(search_pattern)) | (col(CashuTransaction.token).like(search_pattern)) | (col(CashuTransaction.request_id).like(search_pattern)) + | (col(CashuTransaction.api_key_hashed_key).like(search_pattern)) ) stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit) diff --git a/routstr/core/db.py b/routstr/core/db.py index 35f3818f..04ba32be 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -159,6 +159,12 @@ class CashuTransaction(SQLModel, table=True): # type: ignore default="x-cashu", description="Payment source: x-cashu or apikey", ) + api_key_hashed_key: str | None = Field( + default=None, + foreign_key="api_keys.hashed_key", + index=True, + description="Associated API key hash for wallet history", + ) async def store_cashu_transaction( @@ -171,6 +177,7 @@ async def store_cashu_transaction( collected: bool = False, created_at: int | None = None, source: str = "x-cashu", + api_key_hashed_key: str | None = None, ) -> None: try: async with create_session() as session: @@ -184,6 +191,7 @@ async def store_cashu_transaction( collected=collected, created_at=created_at or int(time.time()), source=source, + api_key_hashed_key=api_key_hashed_key, ) session.add(tx) await session.commit() diff --git a/routstr/wallet.py b/routstr/wallet.py index d93f4054..9c27ca56 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -8,6 +8,7 @@ from cashu.wallet.wallet import Wallet from sqlmodel import col, select, update from .core import db, get_logger +from .core.db import store_cashu_transaction from .core.settings import settings from .payment.lnurl import raw_send_to_lnurl @@ -279,6 +280,8 @@ async def credit_balance( try: amount, unit, mint_url = await recieve_token(cashu_token) + original_amount = amount + original_unit = unit logger.info( "credit_balance: Token redeemed successfully", extra={"amount": amount, "unit": unit, "mint_url": mint_url}, @@ -310,6 +313,19 @@ async def credit_balance( extra={"new_balance": key.balance}, ) + try: + await store_cashu_transaction( + token=cashu_token, + amount=original_amount, + unit=original_unit, + mint_url=mint_url, + typ="in", + source="apikey", + api_key_hashed_key=key.hashed_key, + ) + except Exception: + pass + logger.info( "Cashu token successfully redeemed and stored", extra={"amount": amount, "unit": unit, "mint_url": mint_url}, diff --git a/tests/integration/test_wallet_refund.py b/tests/integration/test_wallet_refund.py index 6d586845..55dd9a05 100644 --- a/tests/integration/test_wallet_refund.py +++ b/tests/integration/test_wallet_refund.py @@ -13,7 +13,7 @@ import pytest from httpx import AsyncClient from sqlmodel import select -from routstr.core.db import ApiKey +from routstr.core.db import ApiKey, CashuTransaction @pytest.mark.integration @@ -394,6 +394,42 @@ async def test_refund_during_active_usage( assert response.json()["balance"] == 0 +@pytest.mark.integration +@pytest.mark.asyncio +async def test_wallet_history_returns_apikey_transactions( + authenticated_client: AsyncClient, + testmint_wallet: Any, + integration_session: Any, +) -> None: + wallet_response = await authenticated_client.get("/v1/wallet/") + api_key = wallet_response.json()["api_key"] + hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key + + topup_token = await testmint_wallet.mint_tokens(250) + topup_response = await authenticated_client.post( + "/v1/wallet/topup", params={"cashu_token": topup_token} + ) + assert topup_response.status_code == 200 + + refund_response = await authenticated_client.post("/v1/wallet/refund") + assert refund_response.status_code == 200 + + history_response = await authenticated_client.get("/v1/wallet/history") + assert history_response.status_code == 200 + transactions = history_response.json()["transactions"] + assert len(transactions) >= 2 + assert all("api_key_hashed_key" not in tx for tx in transactions) + assert {tx["type"] for tx in transactions} >= {"in", "out"} + + db_result = await integration_session.execute( + select(CashuTransaction).where( + CashuTransaction.api_key_hashed_key == hashed_key + ) + ) + db_transactions = db_result.scalars().all() + assert len(db_transactions) >= 2 + + @pytest.mark.integration @pytest.mark.asyncio async def test_mint_unavailability_handling( diff --git a/tests/integration/test_wallet_topup.py b/tests/integration/test_wallet_topup.py index 06d66a6c..e8835290 100644 --- a/tests/integration/test_wallet_topup.py +++ b/tests/integration/test_wallet_topup.py @@ -11,7 +11,7 @@ import pytest from httpx import AsyncClient from sqlmodel import select -from routstr.core.db import ApiKey +from routstr.core.db import ApiKey, CashuTransaction from .utils import ( CashuTokenGenerator, @@ -71,6 +71,16 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def] assert db_key.balance == new_balance assert db_key.balance == initial_balance + (topup_amount * 1000) + tx_result = await integration_session.execute( + select(CashuTransaction).where( + CashuTransaction.token == token, + CashuTransaction.type == "in", + ) + ) + tx = tx_result.scalar_one() + assert tx.api_key_hashed_key == hashed_key + assert tx.source == "apikey" + @pytest.mark.integration @pytest.mark.asyncio diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index ef3d3f91..11456367 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -6,6 +6,7 @@ from fastapi.responses import JSONResponse from routstr.balance import refund_wallet_endpoint from routstr.core.db import ApiKey, CashuTransaction +from routstr.wallet import credit_balance def _make_cashu_tx( @@ -29,6 +30,12 @@ def _exec_result(tx: CashuTransaction | None) -> MagicMock: return result +def _update_result(rowcount: int) -> MagicMock: + result = MagicMock() + result.rowcount = rowcount + return result + + @pytest.mark.asyncio async def test_refund_x_cashu_returns_token() -> None: x_cashu_token = "cashuAtest_token_value" @@ -161,6 +168,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No refund_token = "cashuArefund_apikey_token" session = MagicMock() + session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() @@ -186,6 +194,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No assert call_kwargs["source"] == "apikey" assert call_kwargs["token"] == refund_token assert call_kwargs["typ"] == "out" + assert call_kwargs["api_key_hashed_key"] == key.hashed_key @pytest.mark.asyncio @@ -194,6 +203,7 @@ async def test_apikey_refund_logs_token() -> None: refund_token = "cashuAlogged_token" session = MagicMock() + session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() @@ -222,6 +232,7 @@ async def test_apikey_refund_log_includes_path() -> None: refund_token = "cashuApath_token" session = MagicMock() + session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() @@ -248,3 +259,99 @@ async def test_apikey_refund_log_includes_path() -> None: assert len(token_issued_calls) == 1 extra = token_issued_calls[0].kwargs.get("extra", {}) assert extra.get("path") == "/v1/wallet/refund" + + +@pytest.mark.asyncio +async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None: + """When the debit CAS fails (rowcount=0), no token is minted and 409 is returned.""" + from fastapi import HTTPException + + key = _make_api_key(balance=5000, refund_currency="sat") + + session = MagicMock() + # Debit returns rowcount=0 → balance changed concurrently + session.exec = AsyncMock(return_value=_update_result(0)) + session.commit = AsyncMock() + + mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted") + + with ( + patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)), + 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)), + patch("routstr.balance._refund_cache_set", AsyncMock()), + ): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization="Bearer sk-testhash", + x_cashu=None, + session=session, + ) + + assert exc_info.value.status_code == 409 + # Crucially: send_token must NOT have been called + mock_send_token.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_credit_balance_stores_apikey_transaction_history() -> None: + key = _make_api_key(balance=1000) + session = MagicMock() + session.exec = AsyncMock(return_value=_update_result(1)) + session.commit = AsyncMock() + session.refresh = AsyncMock() + + with ( + patch( + "routstr.wallet.recieve_token", + AsyncMock(return_value=(100, "sat", "https://mint.example")), + ), + patch("routstr.wallet.store_cashu_transaction", AsyncMock()) as mock_store, + ): + amount = await credit_balance("cashuAtopup_token", key, session) + + assert amount == 100_000 + mock_store.assert_awaited_once() + call_kwargs = mock_store.call_args.kwargs + assert call_kwargs["typ"] == "in" + assert call_kwargs["source"] == "apikey" + assert call_kwargs["api_key_hashed_key"] == key.hashed_key + assert call_kwargs["amount"] == 100 + assert call_kwargs["unit"] == "sat" + assert call_kwargs["token"] == "cashuAtopup_token" + assert call_kwargs["mint_url"] == "https://mint.example" + + +@pytest.mark.asyncio +async def test_apikey_refund_restores_balance_on_mint_failure() -> None: + """When debit succeeds but minting fails, balance must be restored.""" + from fastapi import HTTPException + + key = _make_api_key(balance=5000, refund_currency="sat") + + # First exec call = debit (succeeds), second = restore + session = MagicMock() + session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)]) + session.commit = AsyncMock() + + with ( + patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)), + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))), + patch("routstr.balance.store_cashu_transaction", AsyncMock()), + patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), + patch("routstr.balance._refund_cache_set", AsyncMock()), + patch("routstr.balance.logger"), + ): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization="Bearer sk-testhash", + x_cashu=None, + session=session, + ) + + assert exc_info.value.status_code == 503 + # Verify two exec calls: debit + restore + assert session.exec.await_count == 2 diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx index 19e0f032..8c8f75e2 100644 --- a/ui/app/transactions/page.tsx +++ b/ui/app/transactions/page.tsx @@ -92,6 +92,7 @@ function TransactionTable({ Type Amount Status + API Key Request ID Mint Date @@ -115,6 +116,31 @@ function TransactionTable({ {tx.amount} {tx.unit} {getStatusBadge(tx)} + + {tx.api_key_hashed_key ? ( +
+ + {tx.api_key_hashed_key.slice(0, 12)}... + + +
+ ) : ( + — + )} +
{tx.request_id ? (
@@ -325,7 +351,7 @@ export default function TransactionsPage() { setSearch(e.target.value)} @@ -385,7 +411,7 @@ export default function TransactionsPage() { - API Key Refunds + API Key {data && ( {apikeyTxs.length} @@ -416,7 +442,7 @@ export default function TransactionsPage() {
- API Key Refund History + API Key Transaction History {hasActiveFilters && ( Filtered by {activeFilterDescription} diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index 1c254f29..192d653c 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -1136,6 +1136,7 @@ export interface Transaction { collected: boolean; swept: boolean; source: 'x-cashu' | 'apikey'; + api_key_hashed_key?: string; } export interface TransactionsResponse {